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}