Skip to main content

mermaid_text/
types.rs

1//! Core types shared across parsing, layout, and rendering.
2
3use std::collections::HashMap;
4
5/// The direction in which a flowchart flows.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum Direction {
8    /// Left-to-right (`LR`).
9    LeftToRight,
10    /// Top-to-bottom (`TD` or `TB`).
11    TopToBottom,
12    /// Right-to-left (`RL`).
13    RightToLeft,
14    /// Bottom-to-top (`BT`).
15    BottomToTop,
16}
17
18impl Direction {
19    /// Parse a direction keyword, case-insensitive.
20    ///
21    /// # Arguments
22    ///
23    /// * `s` — a direction token such as `"LR"`, `"TD"`, `"TB"`, `"RL"`, or `"BT"`.
24    ///
25    /// # Returns
26    ///
27    /// `Some(Direction)` if the keyword is recognised, `None` otherwise.
28    ///
29    /// # Examples
30    ///
31    /// ```
32    /// use mermaid_text::Direction;
33    ///
34    /// assert_eq!(Direction::parse("LR"), Some(Direction::LeftToRight));
35    /// assert_eq!(Direction::parse("td"), Some(Direction::TopToBottom)); // case-insensitive
36    /// assert_eq!(Direction::parse("TB"), Some(Direction::TopToBottom));
37    /// assert_eq!(Direction::parse("RL"), Some(Direction::RightToLeft));
38    /// assert_eq!(Direction::parse("BT"), Some(Direction::BottomToTop));
39    /// assert_eq!(Direction::parse("XX"), None);
40    /// ```
41    pub fn parse(s: &str) -> Option<Self> {
42        match s.to_uppercase().as_str() {
43            "LR" => Some(Self::LeftToRight),
44            "TD" | "TB" => Some(Self::TopToBottom),
45            "RL" => Some(Self::RightToLeft),
46            "BT" => Some(Self::BottomToTop),
47            _ => None,
48        }
49    }
50
51    /// Returns `true` if the primary flow axis is horizontal (LR or RL).
52    ///
53    /// # Examples
54    ///
55    /// ```
56    /// use mermaid_text::Direction;
57    ///
58    /// assert!(Direction::LeftToRight.is_horizontal());
59    /// assert!(Direction::RightToLeft.is_horizontal());
60    /// assert!(!Direction::TopToBottom.is_horizontal());
61    /// assert!(!Direction::BottomToTop.is_horizontal());
62    /// ```
63    pub fn is_horizontal(self) -> bool {
64        matches!(self, Self::LeftToRight | Self::RightToLeft)
65    }
66}
67
68/// The visual shape used to render a node.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum NodeShape {
71    /// Square corners: `┌──┐ │  │ └──┘`
72    #[default]
73    Rectangle,
74    /// Rounded corners: `╭──╮ │  │ ╰──╯`
75    Rounded,
76    /// Diamond / decision box rendered with `/` and `\` corners.
77    Diamond,
78    /// Circle rendered as a rounded box with parenthesis markers.
79    Circle,
80    /// Stadium / pill: rounded box with `(` / `)` markers at vertical midpoints.
81    ///
82    /// Mermaid syntax: `([label])`
83    Stadium,
84    /// Subroutine: rectangle with an extra inner vertical bar on each side.
85    ///
86    /// Mermaid syntax: `[[label]]`
87    Subroutine,
88    /// Cylinder (database): rectangle with arc markers at top and bottom centres.
89    ///
90    /// Mermaid syntax: `[(label)]`
91    Cylinder,
92    /// Hexagon: rectangle with `<` / `>` markers at vertical midpoints of left/right edges.
93    ///
94    /// Mermaid syntax: `{{label}}`
95    Hexagon,
96    /// Asymmetric flag: rectangle with a `⟩` marker at the right vertical midpoint.
97    ///
98    /// Mermaid syntax: `>label]`
99    Asymmetric,
100    /// Parallelogram (lean-right): rectangle with `/` markers at top-left / bottom-right corners.
101    ///
102    /// Mermaid syntax: `[/label/]`
103    Parallelogram,
104    /// Trapezoid (wider top): rectangle with `/` at top-left and `\` at top-right corners.
105    ///
106    /// Mermaid syntax: `[/label\]`
107    Trapezoid,
108    /// Parallelogram leaning left (backslash variant): rectangle with `\` markers at
109    /// top-left and bottom-right corners.
110    ///
111    /// Mermaid syntax: `[\label\]`
112    ParallelogramBackslash,
113    /// Inverted trapezoid (wider bottom): rectangle with `\` at top-left and `/` at
114    /// top-right corners, indicating a narrower top.
115    ///
116    /// Mermaid syntax: `[\label/]`
117    TrapezoidInverted,
118    /// Double circle: two concentric rounded boxes, one cell inside the other.
119    ///
120    /// Mermaid syntax: `(((label)))`
121    DoubleCircle,
122    /// UML synchronisation bar — a single line used as a fork (one
123    /// incoming, many outgoing) or join (many incoming, one
124    /// outgoing) point in parallel-flow state machines.
125    ///
126    /// The orientation is **perpendicular to the flow direction**
127    /// (so edges fan in/out across its long axis):
128    ///
129    /// - In LR/RL flow: [`BarOrientation::Vertical`] — a column of `┃`
130    ///   glyphs.
131    /// - In TD/BT flow: [`BarOrientation::Horizontal`] — a row of `━`
132    ///   glyphs.
133    ///
134    /// Fork and join are visually identical (only the semantic role
135    /// differs); both use this single shape variant. The renderer
136    /// skips drawing the node label for `Bar(_)` shapes — bars are
137    /// connection points, not labelled states.
138    ///
139    /// Mermaid syntax: `state X <<fork>>` / `state X <<join>>`
140    /// (and the `[[…]]` alternative spellings).
141    Bar(BarOrientation),
142    /// State-diagram note (Mermaid `note left|right|over of …`).
143    /// Synthesised at parse time as a regular [`Node`] with this
144    /// shape and connected to its anchor via an [`EdgeStyle::Dotted`]
145    /// / [`EdgeEndpoint::None`] edge. Renders as a small rounded box
146    /// — the dotted connector visually distinguishes it from regular
147    /// rounded states without needing a separate dashed-border
148    /// primitive (a future variant could add one).
149    Note,
150}
151
152/// Orientation for a [`NodeShape::Bar`] (fork/join synchronisation bar).
153///
154/// Resolved at parse time from the graph's flow direction so the
155/// renderer doesn't need direction context — the bar is always
156/// perpendicular to flow:
157///
158/// - `Horizontal` for TD/BT-flow diagrams (row of `━`).
159/// - `Vertical` for LR/RL-flow diagrams (column of `┃`).
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum BarOrientation {
162    /// `━━━` — used for fork/join in TD/BT-flow diagrams.
163    Horizontal,
164    /// `┃` stacked — used for fork/join in LR/RL-flow diagrams.
165    Vertical,
166}
167
168/// A 24-bit RGB color, used for ANSI truecolor SGR sequences.
169///
170/// Parsed from Mermaid `style` / `linkStyle` directives like
171/// `fill:#336`, `stroke:#ffffff`, `color:#fff`. Both `#RGB` and
172/// `#RRGGBB` forms are accepted.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub struct Rgb(pub u8, pub u8, pub u8);
175
176impl Rgb {
177    /// Parse a Mermaid hex color of the form `#RGB` or `#RRGGBB`
178    /// (case-insensitive). The leading `#` is required.
179    ///
180    /// # Examples
181    ///
182    /// ```
183    /// use mermaid_text::types::Rgb;
184    ///
185    /// assert_eq!(Rgb::parse_hex("#336"), Some(Rgb(0x33, 0x33, 0x66)));
186    /// assert_eq!(Rgb::parse_hex("#FFFFFF"), Some(Rgb(0xff, 0xff, 0xff)));
187    /// assert_eq!(Rgb::parse_hex("336"), None); // missing '#'
188    /// assert_eq!(Rgb::parse_hex("#GG0000"), None); // not hex
189    /// ```
190    pub fn parse_hex(s: &str) -> Option<Self> {
191        let body = s.strip_prefix('#')?;
192        match body.len() {
193            3 => {
194                let r = u8::from_str_radix(&body[0..1], 16).ok()?;
195                let g = u8::from_str_radix(&body[1..2], 16).ok()?;
196                let b = u8::from_str_radix(&body[2..3], 16).ok()?;
197                Some(Self(r * 0x11, g * 0x11, b * 0x11))
198            }
199            6 => {
200                let r = u8::from_str_radix(&body[0..2], 16).ok()?;
201                let g = u8::from_str_radix(&body[2..4], 16).ok()?;
202                let b = u8::from_str_radix(&body[4..6], 16).ok()?;
203                Some(Self(r, g, b))
204            }
205            _ => None,
206        }
207    }
208}
209
210/// Per-node style attributes parsed from `style <id> ...` directives.
211///
212/// Only color-related attributes are tracked. Unrecognised keys
213/// (e.g. `font-size`) are silently ignored at parse time.
214#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
215pub struct NodeStyle {
216    /// Background color for the node interior cells (`fill:#…`).
217    pub fill: Option<Rgb>,
218    /// Foreground color for the node border glyphs (`stroke:#…`).
219    pub stroke: Option<Rgb>,
220    /// Foreground color for the node label text (`color:#…`).
221    pub color: Option<Rgb>,
222}
223
224/// Per-edge color attributes parsed from `linkStyle <index> ...` directives.
225#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
226pub struct EdgeStyleColors {
227    /// Foreground color for the edge glyphs (`stroke:#…`).
228    pub stroke: Option<Rgb>,
229    /// Foreground color for the edge label text (`color:#…`).
230    pub color: Option<Rgb>,
231}
232
233/// The visual style of an edge line.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
235pub enum EdgeStyle {
236    /// Solid line (default). Characters: `─` / `│`.
237    #[default]
238    Solid,
239    /// Dotted line. Characters: `┄` / `┆`.
240    Dotted,
241    /// Thick / bold line. Characters: `━` / `┃`.
242    Thick,
243}
244
245/// The kind of endpoint drawn at each end of an edge.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
247pub enum EdgeEndpoint {
248    /// An arrow tip pointing in the direction of travel.
249    #[default]
250    Arrow,
251    /// No arrow tip — just the line reaching the node border.
252    None,
253    /// A circle endpoint (`○`).
254    Circle,
255    /// A cross endpoint (`×`).
256    Cross,
257}
258
259/// A hyperlink target attached to a node via a Mermaid `click` directive.
260///
261/// When rendered in a terminal that supports OSC 8, the node's label text is
262/// wrapped with the appropriate escape sequences so it becomes a clickable
263/// hyperlink. In terminals that do not support OSC 8 the escape bytes are
264/// emitted but harmlessly ignored (or stripped by [`crate::to_ascii`] in ASCII
265/// mode).
266///
267/// # Examples
268///
269/// ```
270/// use mermaid_text::types::ClickTarget;
271///
272/// let ct = ClickTarget { url: "https://example.com".to_string(), tooltip: None };
273/// assert_eq!(ct.url, "https://example.com");
274/// ```
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct ClickTarget {
277    /// The URL to open when the node label is clicked.
278    pub url: String,
279    /// Optional tooltip text (from the third argument of `click NodeId "url" "tooltip"`).
280    /// Not rendered in the terminal output but preserved for future use.
281    pub tooltip: Option<String>,
282}
283
284/// A single node in the diagram.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct Node {
287    /// Unique identifier used in edge definitions (e.g. `A`).
288    pub id: String,
289    /// Human-readable label displayed inside the node box.
290    pub label: String,
291    /// Visual shape of the node.
292    pub shape: NodeShape,
293}
294
295impl Node {
296    /// Construct a new node.
297    ///
298    /// # Arguments
299    ///
300    /// * `id`    — unique identifier used in edge definitions
301    /// * `label` — human-readable text displayed inside the node box
302    /// * `shape` — visual shape of the node
303    ///
304    /// # Examples
305    ///
306    /// ```
307    /// use mermaid_text::{Node, NodeShape};
308    ///
309    /// let node = Node::new("A", "Start", NodeShape::Rounded);
310    /// assert_eq!(node.id, "A");
311    /// assert_eq!(node.label, "Start");
312    /// assert_eq!(node.shape, NodeShape::Rounded);
313    /// ```
314    pub fn new(id: impl Into<String>, label: impl Into<String>, shape: NodeShape) -> Self {
315        Self {
316            id: id.into(),
317            label: label.into(),
318            shape,
319        }
320    }
321
322    /// Return the widest line of the label, measured in terminal cells.
323    ///
324    /// Labels may contain `\n` line breaks (inserted by the parser when
325    /// converting `<br/>` tags or when soft-wrapping long lines). The
326    /// renderer sizes node boxes by the widest single line rather than by
327    /// the whole label string, so the parser-inserted breaks actually
328    /// narrow the box.
329    ///
330    /// Returns `0` for an empty label.
331    pub fn label_width(&self) -> usize {
332        use unicode_width::UnicodeWidthStr;
333        self.label
334            .lines()
335            .map(UnicodeWidthStr::width)
336            .max()
337            .unwrap_or(0)
338    }
339
340    /// Return the number of rendered text rows this node's label occupies.
341    ///
342    /// Always at least 1, even for empty labels, so node boxes retain their
343    /// minimum height.
344    pub fn label_line_count(&self) -> usize {
345        let n = self.label.lines().count();
346        n.max(1)
347    }
348}
349
350/// A directed connection between two nodes.
351#[derive(Debug, Clone, PartialEq, Eq)]
352pub struct Edge {
353    /// ID of the source node.
354    pub from: String,
355    /// ID of the destination node.
356    pub to: String,
357    /// Optional label placed along the edge.
358    pub label: Option<String>,
359    /// Visual style of the edge line (solid, dotted, or thick).
360    pub style: EdgeStyle,
361    /// Endpoint drawn at the **destination** end.
362    pub end: EdgeEndpoint,
363    /// Endpoint drawn at the **source** end (for bidirectional edges).
364    pub start: EdgeEndpoint,
365}
366
367impl Edge {
368    /// Construct a new solid arrow edge (the most common case).
369    ///
370    /// Equivalent to `new_styled` with [`EdgeStyle::Solid`], [`EdgeEndpoint::None`]
371    /// at the source, and [`EdgeEndpoint::Arrow`] at the destination.
372    ///
373    /// # Arguments
374    ///
375    /// * `from`  — source node ID
376    /// * `to`    — destination node ID
377    /// * `label` — optional label placed along the edge
378    ///
379    /// # Examples
380    ///
381    /// ```
382    /// use mermaid_text::{Edge, EdgeEndpoint, EdgeStyle};
383    ///
384    /// let e = Edge::new("A", "B", Some("ok".to_string()));
385    /// assert_eq!(e.from, "A");
386    /// assert_eq!(e.to, "B");
387    /// assert_eq!(e.label.as_deref(), Some("ok"));
388    /// assert_eq!(e.style, EdgeStyle::Solid);
389    /// assert_eq!(e.end, EdgeEndpoint::Arrow);
390    /// assert_eq!(e.start, EdgeEndpoint::None);
391    /// ```
392    pub fn new(from: impl Into<String>, to: impl Into<String>, label: Option<String>) -> Self {
393        Self {
394            from: from.into(),
395            to: to.into(),
396            label,
397            style: EdgeStyle::Solid,
398            end: EdgeEndpoint::Arrow,
399            start: EdgeEndpoint::None,
400        }
401    }
402
403    /// Construct an edge with explicit style and endpoint kinds.
404    ///
405    /// # Arguments
406    ///
407    /// * `from`  — source node ID
408    /// * `to`    — destination node ID
409    /// * `label` — optional label placed along the edge
410    /// * `style` — line style (solid, dotted, thick)
411    /// * `start` — endpoint at the source end
412    /// * `end`   — endpoint at the destination end
413    ///
414    /// # Examples
415    ///
416    /// ```
417    /// use mermaid_text::{Edge, EdgeEndpoint, EdgeStyle};
418    ///
419    /// // A bidirectional thick edge with a label
420    /// let e = Edge::new_styled(
421    ///     "A", "B",
422    ///     Some("sync".to_string()),
423    ///     EdgeStyle::Thick,
424    ///     EdgeEndpoint::Arrow,
425    ///     EdgeEndpoint::Arrow,
426    /// );
427    /// assert_eq!(e.style, EdgeStyle::Thick);
428    /// assert_eq!(e.start, EdgeEndpoint::Arrow);
429    /// assert_eq!(e.end, EdgeEndpoint::Arrow);
430    /// ```
431    pub fn new_styled(
432        from: impl Into<String>,
433        to: impl Into<String>,
434        label: Option<String>,
435        style: EdgeStyle,
436        start: EdgeEndpoint,
437        end: EdgeEndpoint,
438    ) -> Self {
439        Self {
440            from: from.into(),
441            to: to.into(),
442            label,
443            style,
444            end,
445            start,
446        }
447    }
448}
449
450/// A named cluster of nodes (and optionally nested subgraphs).
451///
452/// Subgraphs are rendered as a rounded rectangle that encloses all their
453/// direct and indirect member nodes. Edges may freely cross subgraph
454/// boundaries.
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub struct Subgraph {
457    /// Unique identifier (the `id` token after `subgraph`).
458    pub id: String,
459    /// Human-readable label displayed at the top of the border. Falls back
460    /// to `id` when not explicitly specified.
461    pub label: String,
462    /// Optional per-subgraph flow direction override.
463    ///
464    /// Currently preserved on the model for future use; the renderer
465    /// always uses the parent graph direction.
466    pub direction: Option<Direction>,
467    /// IDs of **direct** child nodes (not recursively nested ones).
468    pub node_ids: Vec<String>,
469    /// IDs of **direct** child subgraphs.
470    pub subgraph_ids: Vec<String>,
471}
472
473impl Subgraph {
474    /// Construct a new subgraph with the given id and label.
475    ///
476    /// Both `node_ids` and `subgraph_ids` start empty; the parser fills them
477    /// as it processes the subgraph body. `direction` defaults to `None`
478    /// (inherits from the parent graph).
479    ///
480    /// # Arguments
481    ///
482    /// * `id`    — unique identifier (the token after `subgraph`)
483    /// * `label` — display label at the top of the border
484    ///
485    /// # Examples
486    ///
487    /// ```
488    /// use mermaid_text::types::Subgraph;
489    ///
490    /// let sg = Subgraph::new("S1", "My Cluster");
491    /// assert_eq!(sg.id, "S1");
492    /// assert_eq!(sg.label, "My Cluster");
493    /// assert!(sg.node_ids.is_empty());
494    /// assert!(sg.direction.is_none());
495    /// ```
496    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
497        let id = id.into();
498        let label = label.into();
499        Self {
500            id,
501            label,
502            direction: None,
503            node_ids: Vec::new(),
504            subgraph_ids: Vec::new(),
505        }
506    }
507}
508
509/// A parsed flowchart graph ready for layout and rendering.
510#[derive(Debug, Clone, PartialEq, Eq)]
511pub struct Graph {
512    /// The overall flow direction.
513    pub direction: Direction,
514    /// All nodes in declaration order.
515    pub nodes: Vec<Node>,
516    /// All edges in declaration order.
517    pub edges: Vec<Edge>,
518    /// All top-level subgraphs in declaration order.
519    ///
520    /// Subgraphs may nest: a subgraph's `subgraph_ids` list references the
521    /// IDs of its immediate children. Use [`Graph::node_to_subgraph`] for
522    /// efficient node→subgraph lookups.
523    pub subgraphs: Vec<Subgraph>,
524    /// Per-node color overrides parsed from `style <id> ...` directives.
525    ///
526    /// Empty by default; populated only when the source contains `style`
527    /// directives. Used by the renderer when ANSI color output is enabled.
528    pub node_styles: HashMap<String, NodeStyle>,
529    /// Per-edge color overrides parsed from `linkStyle <index> ...` directives.
530    ///
531    /// Keyed by the edge's positional index (0-based, in declaration order).
532    /// Empty by default.
533    pub edge_styles: HashMap<usize, EdgeStyleColors>,
534    /// Named style classes from `classDef name fill:#…,stroke:#…,color:#…`
535    /// directives. Acts as the palette that `class A foo` and `A:::foo`
536    /// look up at end-of-parse to populate `node_styles` /
537    /// `subgraph_styles`. Multiple `classDef` entries with the same name
538    /// are last-wins (matches Mermaid).
539    pub class_defs: HashMap<String, NodeStyle>,
540    /// Per-subgraph color overrides — populated when `class CompositeId
541    /// styleName` is applied to a known composite/subgraph id. The
542    /// renderer paints the rounded border with `stroke`. `fill` and
543    /// `color` are accepted in the schema for consistency with
544    /// `node_styles` but only `stroke` is honoured today (filling a
545    /// composite's interior would conflict with inner node fills).
546    pub subgraph_styles: HashMap<String, NodeStyle>,
547    /// Hyperlink targets from `click NodeId "url"` directives.
548    ///
549    /// Keyed by node ID; present only for nodes that have an explicit
550    /// `click` directive with a URL. JS-callback forms (`click NodeId
551    /// callbackFn`) are silently ignored.
552    ///
553    /// Used by the renderer to wrap node labels in OSC 8 hyperlink
554    /// escape sequences when emitting Unicode output.
555    pub click_targets: HashMap<String, ClickTarget>,
556}
557
558impl Graph {
559    /// Construct a new empty graph with the given direction.
560    ///
561    /// # Arguments
562    ///
563    /// * `direction` — the overall flow direction for this graph
564    ///
565    /// # Examples
566    ///
567    /// ```
568    /// use mermaid_text::{Graph, Direction};
569    ///
570    /// let g = Graph::new(Direction::LeftToRight);
571    /// assert_eq!(g.direction, Direction::LeftToRight);
572    /// assert!(g.nodes.is_empty());
573    /// assert!(g.edges.is_empty());
574    /// ```
575    pub fn new(direction: Direction) -> Self {
576        Self {
577            direction,
578            nodes: Vec::new(),
579            edges: Vec::new(),
580            subgraphs: Vec::new(),
581            node_styles: HashMap::new(),
582            edge_styles: HashMap::new(),
583            class_defs: HashMap::new(),
584            subgraph_styles: HashMap::new(),
585            click_targets: HashMap::new(),
586        }
587    }
588
589    /// Look up a node by its ID, returning a reference if found.
590    ///
591    /// # Arguments
592    ///
593    /// * `id` — the node identifier to search for
594    ///
595    /// # Examples
596    ///
597    /// ```
598    /// use mermaid_text::{Graph, Node, NodeShape, Direction};
599    ///
600    /// let mut g = Graph::new(Direction::LeftToRight);
601    /// g.nodes.push(Node::new("A", "Start", NodeShape::Rectangle));
602    /// assert_eq!(g.node("A").map(|n| n.label.as_str()), Some("Start"));
603    /// assert!(g.node("Z").is_none());
604    /// ```
605    pub fn node(&self, id: &str) -> Option<&Node> {
606        self.nodes.iter().find(|n| n.id == id)
607    }
608
609    /// Return `true` if a node with `id` already exists.
610    ///
611    /// # Examples
612    ///
613    /// ```
614    /// use mermaid_text::{Graph, Node, NodeShape, Direction};
615    ///
616    /// let mut g = Graph::new(Direction::TopToBottom);
617    /// g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
618    /// assert!(g.has_node("A"));
619    /// assert!(!g.has_node("B"));
620    /// ```
621    pub fn has_node(&self, id: &str) -> bool {
622        self.nodes.iter().any(|n| n.id == id)
623    }
624
625    /// Insert a node, or update its label/shape if the ID already exists and
626    /// the existing entry was auto-created as a bare-id placeholder.
627    ///
628    /// A "bare-id placeholder" is a node whose `label == id` and `shape == Rectangle`
629    /// (the default produced when a node is first seen in an edge definition
630    /// without an explicit shape). If such a placeholder already exists and the
631    /// incoming `node` has a richer definition (different label or non-default shape),
632    /// the placeholder is promoted to the richer definition.
633    ///
634    /// # Examples
635    ///
636    /// ```
637    /// use mermaid_text::{Graph, Node, NodeShape, Direction};
638    ///
639    /// let mut g = Graph::new(Direction::LeftToRight);
640    /// // Insert a bare-id placeholder.
641    /// g.upsert_node(Node::new("A", "A", NodeShape::Rectangle));
642    /// // Promote it to a richer definition.
643    /// g.upsert_node(Node::new("A", "Start", NodeShape::Rounded));
644    /// assert_eq!(g.node("A").unwrap().label, "Start");
645    /// assert_eq!(g.node("A").unwrap().shape, NodeShape::Rounded);
646    /// // If neither condition holds, the existing entry is kept.
647    /// g.upsert_node(Node::new("A", "Other", NodeShape::Diamond));
648    /// assert_eq!(g.node("A").unwrap().label, "Start"); // unchanged
649    /// ```
650    pub fn upsert_node(&mut self, node: Node) {
651        if let Some(existing) = self.nodes.iter_mut().find(|n| n.id == node.id) {
652            // Only promote a bare placeholder (label == id) to a richer definition.
653            if existing.label == existing.id
654                && (existing.shape == NodeShape::Rectangle)
655                && (node.label != node.id || node.shape != NodeShape::Rectangle)
656            {
657                *existing = node;
658            }
659        } else {
660            self.nodes.push(node);
661        }
662    }
663
664    /// Build a flat map from node ID → the ID of the **innermost** subgraph
665    /// that contains it (only direct `node_ids` members, not transitive).
666    ///
667    /// The map is computed on demand and not cached — call this once per
668    /// render pass and keep the result locally.
669    ///
670    /// # Examples
671    ///
672    /// ```
673    /// let graph = mermaid_text::parser::parse(
674    ///     "graph LR\nsubgraph S\nA-->B\nend\nC",
675    /// ).unwrap();
676    /// let map = graph.node_to_subgraph();
677    /// assert_eq!(map.get("A").map(String::as_str), Some("S"));
678    /// assert_eq!(map.get("B").map(String::as_str), Some("S"));
679    /// assert!(map.get("C").is_none());
680    /// ```
681    pub fn node_to_subgraph(&self) -> HashMap<String, String> {
682        let mut map = HashMap::new();
683        // Walk all subgraphs (including nested ones reachable via subgraph_ids)
684        // depth-first so that inner subgraphs overwrite outer ones for direct members.
685        for sg in &self.subgraphs {
686            self.collect_node_subgraph_map(sg, &mut map);
687        }
688        map
689    }
690
691    /// Recursive helper: walk `sg` and all its descendants, inserting
692    /// node_id → sg.id for **direct** children (children of a child subgraph
693    /// are overwritten by that child's own recursive call).
694    fn collect_node_subgraph_map(&self, sg: &Subgraph, map: &mut HashMap<String, String>) {
695        // Register direct node members first.
696        for nid in &sg.node_ids {
697            map.insert(nid.clone(), sg.id.clone());
698        }
699        // Recurse into nested subgraphs — their entries overwrite ours for
700        // any nodes that appear in both (Mermaid allows implicit membership
701        // through nesting).
702        for child_id in &sg.subgraph_ids {
703            if let Some(child) = self.find_subgraph(child_id) {
704                self.collect_node_subgraph_map(child, map);
705            }
706        }
707    }
708
709    /// Find a subgraph by ID, searching recursively through all nesting levels.
710    ///
711    /// # Arguments
712    ///
713    /// * `id` — the subgraph identifier to search for
714    ///
715    /// # Examples
716    ///
717    /// ```
718    /// let graph = mermaid_text::parser::parse(
719    ///     "graph TD\nsubgraph Outer\nsubgraph Inner\nA\nend\nend",
720    /// ).unwrap();
721    /// assert!(graph.find_subgraph("Outer").is_some());
722    /// assert!(graph.find_subgraph("Inner").is_some());
723    /// assert!(graph.find_subgraph("Missing").is_none());
724    /// ```
725    pub fn find_subgraph(&self, id: &str) -> Option<&Subgraph> {
726        fn search<'a>(sgs: &'a [Subgraph], all: &'a [Subgraph], id: &str) -> Option<&'a Subgraph> {
727            for sg in sgs {
728                if sg.id == id {
729                    return Some(sg);
730                }
731                // Search in nested subgraphs by looking up their IDs.
732                for child_id in &sg.subgraph_ids {
733                    if let Some(found) = all.iter().find(|s| &s.id == child_id)
734                        && let Some(result) = search(std::slice::from_ref(found), all, id)
735                    {
736                        return Some(result);
737                    }
738                }
739            }
740            None
741        }
742        search(&self.subgraphs, &self.subgraphs, id)
743    }
744
745    /// Collect all node IDs that belong to `sg` or any of its nested subgraphs.
746    ///
747    /// This is a deep traversal: nodes in nested subgraphs within `sg` are
748    /// included in the result, not just direct `sg.node_ids` members.
749    ///
750    /// # Arguments
751    ///
752    /// * `sg` — the subgraph to collect nodes from (including descendants)
753    ///
754    /// # Examples
755    ///
756    /// ```
757    /// let graph = mermaid_text::parser::parse(
758    ///     "graph TD\nsubgraph Outer\nsubgraph Inner\nA\nend\nB\nend",
759    /// ).unwrap();
760    /// let outer = graph.find_subgraph("Outer").unwrap();
761    /// let nodes = graph.all_nodes_in_subgraph(outer);
762    /// assert!(nodes.contains(&"A".to_string()));
763    /// assert!(nodes.contains(&"B".to_string()));
764    /// ```
765    pub fn all_nodes_in_subgraph(&self, sg: &Subgraph) -> Vec<String> {
766        let mut result = sg.node_ids.clone();
767        for child_id in &sg.subgraph_ids {
768            if let Some(child) = self.find_subgraph(child_id) {
769                result.extend(self.all_nodes_in_subgraph(child));
770            }
771        }
772        result
773    }
774
775    /// Group edge indices by their unordered endpoint pair, returning
776    /// only the groups containing more than one edge.
777    ///
778    /// Two edges are "parallel" iff they share the same unordered
779    /// `(from, to)` endpoints — so `F → W` and `W → F` belong to the
780    /// same group, as do `T ==>|pass| D` and `T -.->|skip| D`.
781    /// Self-loops (`A → A`) are kept as singleton groups; they're
782    /// included in the output only when an entity has multiple
783    /// self-loops, which is rare but possible.
784    ///
785    /// Used by the renderer's parallel-channel allocation pass
786    /// (Phase 2 of the layout-pass widening work — see
787    /// `docs/scope-parallel-edges.md`) to give each edge in a group
788    /// its own row (LR) or column (TD) so labels stack cleanly
789    /// instead of competing for one inter-layer cell.
790    ///
791    /// Returns `Vec<Vec<usize>>` where each inner Vec contains edge
792    /// indices in source order (edges at lower index render first).
793    /// Each inner Vec has length ≥ 2; non-parallel edges are absent
794    /// from the output entirely.
795    pub fn parallel_edge_groups(&self) -> Vec<Vec<usize>> {
796        // Bucket edges by their unordered endpoint pair.
797        let mut groups: std::collections::BTreeMap<(String, String), Vec<usize>> =
798            std::collections::BTreeMap::new();
799        for (idx, edge) in self.edges.iter().enumerate() {
800            let key = if edge.from <= edge.to {
801                (edge.from.clone(), edge.to.clone())
802            } else {
803                (edge.to.clone(), edge.from.clone())
804            };
805            groups.entry(key).or_default().push(idx);
806        }
807        // Filter to actual parallel groups (≥2 edges) and return in
808        // a stable order (source order of the lowest-index edge per
809        // group) so downstream consumers see deterministic output.
810        let mut out: Vec<Vec<usize>> = groups
811            .into_values()
812            .filter(|indices| indices.len() >= 2)
813            .collect();
814        out.sort_by_key(|g| g[0]);
815        out
816    }
817}
818
819// ---------------------------------------------------------------------------
820// Tests
821// ---------------------------------------------------------------------------
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826
827    fn rect(id: &str) -> Node {
828        Node::new(id, id, NodeShape::Rectangle)
829    }
830
831    fn graph_with_edges(edges: &[(&str, &str)]) -> Graph {
832        let mut g = Graph::new(Direction::LeftToRight);
833        let mut seen = std::collections::HashSet::new();
834        for (from, to) in edges {
835            for id in [from, to] {
836                if seen.insert(*id) {
837                    g.nodes.push(rect(id));
838                }
839            }
840            g.edges.push(Edge::new(*from, *to, None));
841        }
842        g
843    }
844
845    // ---- parallel_edge_groups -----------------------------------------
846
847    #[test]
848    fn parallel_groups_empty_for_single_edge() {
849        let g = graph_with_edges(&[("A", "B")]);
850        assert!(g.parallel_edge_groups().is_empty());
851    }
852
853    #[test]
854    fn parallel_groups_empty_for_unrelated_edges() {
855        let g = graph_with_edges(&[("A", "B"), ("C", "D"), ("E", "F")]);
856        assert!(g.parallel_edge_groups().is_empty());
857    }
858
859    #[test]
860    fn parallel_groups_detects_two_same_direction() {
861        // T ==>|pass| D and T -.->|skip| D — the CI/CD case.
862        let g = graph_with_edges(&[("T", "D"), ("T", "D")]);
863        let groups = g.parallel_edge_groups();
864        assert_eq!(groups.len(), 1);
865        assert_eq!(groups[0], vec![0, 1]);
866    }
867
868    #[test]
869    fn parallel_groups_detects_bidirectional_pair() {
870        // F→W and W→F — the Supervisor case.
871        let g = graph_with_edges(&[("F", "W"), ("W", "F")]);
872        let groups = g.parallel_edge_groups();
873        assert_eq!(groups.len(), 1);
874        assert_eq!(groups[0], vec![0, 1]);
875    }
876
877    #[test]
878    fn parallel_groups_detects_three_between_same_pair() {
879        let g = graph_with_edges(&[("A", "B"), ("A", "B"), ("B", "A")]);
880        let groups = g.parallel_edge_groups();
881        assert_eq!(groups.len(), 1);
882        assert_eq!(groups[0], vec![0, 1, 2]);
883    }
884
885    #[test]
886    fn parallel_groups_separates_distinct_pairs() {
887        let g = graph_with_edges(&[
888            ("A", "B"),
889            ("C", "D"),
890            ("A", "B"), // parallel with edge 0
891            ("C", "D"), // parallel with edge 1
892        ]);
893        let groups = g.parallel_edge_groups();
894        assert_eq!(groups.len(), 2);
895        assert_eq!(groups[0], vec![0, 2]);
896        assert_eq!(groups[1], vec![1, 3]);
897    }
898
899    #[test]
900    fn parallel_groups_self_loop_alone_excluded() {
901        // A single self-loop is NOT parallel with anything.
902        let g = graph_with_edges(&[("A", "A")]);
903        assert!(g.parallel_edge_groups().is_empty());
904    }
905
906    #[test]
907    fn parallel_groups_multiple_self_loops_grouped() {
908        // Two self-loops on the same node DO form a parallel group.
909        let g = graph_with_edges(&[("A", "A"), ("A", "A")]);
910        let groups = g.parallel_edge_groups();
911        assert_eq!(groups.len(), 1);
912        assert_eq!(groups[0], vec![0, 1]);
913    }
914
915    #[test]
916    fn parallel_groups_returned_in_source_order() {
917        // Groups appear sorted by the lowest edge index they contain,
918        // so callers see deterministic output regardless of HashMap
919        // iteration order.
920        let g = graph_with_edges(&[
921            ("X", "Y"),
922            ("A", "B"),
923            ("X", "Y"), // parallel with edge 0
924            ("A", "B"), // parallel with edge 1
925        ]);
926        let groups = g.parallel_edge_groups();
927        assert_eq!(groups[0][0], 0); // first group starts at edge 0
928        assert_eq!(groups[1][0], 1); // second group starts at edge 1
929    }
930}