Skip to main content

fd_core/
mermaid.rs

1//! Mermaid diagram import — parse Mermaid `flowchart` syntax into FD `SceneGraph`.
2//!
3//! Supports:
4//! - `flowchart TD` (top-down) / `flowchart LR` (left-right)
5//! - Node shapes: `A[Label]` (rect), `A(Label)` (rounded), `A((Label))` (circle/ellipse),
6//!   `A{Label}` (diamond ≈ rect), `A>Label]` (flag ≈ rect)
7//! - Edges: `A --> B`, `A --- B`, `A -->|text| B`, `A -.-> B`
8//! - Subgraphs: `subgraph name ... end`
9//!
10//! Auto-positions nodes in a grid with 200px spacing based on direction.
11
12use crate::id::NodeId;
13use crate::model::*;
14use std::collections::HashMap;
15
16/// Direction hint parsed from `flowchart TD|TB|LR|RL|BT`.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum FlowDirection {
19    #[default]
20    TopDown,
21    LeftRight,
22    BottomUp,
23    RightLeft,
24}
25
26/// Parsed node shape from Mermaid syntax.
27#[derive(Debug, Clone, PartialEq, Eq)]
28enum MermaidNodeShape {
29    /// `A[Label]` — standard rectangle
30    Rect,
31    /// `A(Label)` — rounded rectangle
32    Rounded,
33    /// `A((Label))` — circle / ellipse
34    Circle,
35    /// `A{Label}` — diamond (approximated as rect)
36    Diamond,
37    /// `A>Label]` — flag (approximated as rect)
38    Flag,
39}
40
41/// A parsed Mermaid node before conversion to FD.
42#[derive(Debug, Clone)]
43struct MermaidNode {
44    id: String,
45    label: String,
46    shape: MermaidNodeShape,
47}
48
49/// A parsed Mermaid edge.
50#[derive(Debug, Clone)]
51struct MermaidEdge {
52    from: String,
53    to: String,
54    /// Full token for the "from" side (e.g. `A(Rounded)`) — used by ensure_node for shape detection.
55    from_token: String,
56    /// Full token for the "to" side.
57    to_token: String,
58    label: Option<String>,
59    has_arrow: bool,
60}
61
62/// A parsed Mermaid subgraph.
63#[derive(Debug, Clone)]
64struct MermaidSubgraph {
65    id: String,
66    label: String,
67    node_ids: Vec<String>,
68}
69
70/// Parse a Mermaid diagram string into an FD `SceneGraph`.
71///
72/// Currently supports `flowchart` diagrams. Other diagram types
73/// (`sequenceDiagram`, `stateDiagram`) return an error.
74///
75/// # Examples
76///
77/// ```
78/// use fd_core::mermaid::parse_mermaid;
79///
80/// let input = "flowchart TD\n    A[Start] --> B[End]";
81/// let graph = parse_mermaid(input).unwrap();
82/// assert!(graph.get_by_id(fd_core::id::NodeId::intern("A")).is_some());
83/// ```
84pub fn parse_mermaid(input: &str) -> Result<SceneGraph, String> {
85    let input = input.trim();
86    if input.is_empty() {
87        return Ok(SceneGraph::new());
88    }
89
90    // Detect diagram type
91    let first_line = input.lines().next().unwrap_or("");
92    let first_word = first_line.split_whitespace().next().unwrap_or("");
93
94    match first_word {
95        "flowchart" | "graph" => parse_flowchart(input),
96        "sequenceDiagram" => Err("sequenceDiagram import is not yet supported".into()),
97        "stateDiagram" | "stateDiagram-v2" => {
98            Err("stateDiagram import is not yet supported".into())
99        }
100        _ => Err(format!(
101            "Unrecognized Mermaid diagram type: '{first_word}'. Expected flowchart, graph, sequenceDiagram, or stateDiagram."
102        )),
103    }
104}
105
106/// Parse a `flowchart` / `graph` diagram.
107fn parse_flowchart(input: &str) -> Result<SceneGraph, String> {
108    let mut lines = input.lines();
109
110    // Parse header: `flowchart TD` or `graph LR`
111    let header = lines.next().unwrap_or("");
112    let direction = parse_direction(header);
113
114    let mut nodes: HashMap<String, MermaidNode> = HashMap::new();
115    let mut edges: Vec<MermaidEdge> = Vec::new();
116    let mut subgraphs: Vec<MermaidSubgraph> = Vec::new();
117    let mut current_subgraph: Option<MermaidSubgraph> = None;
118
119    for line in lines {
120        let trimmed = line.trim();
121
122        // Skip empty lines and comments
123        if trimmed.is_empty() || trimmed.starts_with("%%") {
124            continue;
125        }
126
127        // Handle subgraph start
128        if trimmed.starts_with("subgraph") {
129            let rest = trimmed.strip_prefix("subgraph").unwrap_or("").trim();
130            // Parse: `subgraph id[label]` or `subgraph label`
131            let (sg_id, sg_label) = if let Some((id, label)) = parse_subgraph_header(rest) {
132                (id, label)
133            } else {
134                let clean = sanitize_id(rest);
135                (clean.clone(), rest.to_string())
136            };
137            current_subgraph = Some(MermaidSubgraph {
138                id: sg_id,
139                label: sg_label,
140                node_ids: Vec::new(),
141            });
142            continue;
143        }
144
145        // Handle subgraph end
146        if trimmed == "end" {
147            if let Some(sg) = current_subgraph.take() {
148                subgraphs.push(sg);
149            }
150            continue;
151        }
152
153        // Handle `direction` statement inside subgraph (skip)
154        if trimmed.starts_with("direction ") {
155            continue;
156        }
157
158        // Handle `style` and `classDef` statements (skip)
159        if trimmed.starts_with("style ") || trimmed.starts_with("classDef ") {
160            continue;
161        }
162
163        // Handle `class` statement (skip)
164        if trimmed.starts_with("class ") {
165            continue;
166        }
167
168        // Handle `click` statement (skip)
169        if trimmed.starts_with("click ") {
170            continue;
171        }
172
173        // Try to parse as edge line (may contain implicit node definitions)
174        if let Some(parsed_edges) = try_parse_edge_line(trimmed) {
175            for pe in &parsed_edges {
176                // Ensure nodes exist (implicit definition from edges)
177                // Pass full tokens so shapes like A(Rounded) are detected
178                ensure_node(&mut nodes, &pe.from_token);
179                ensure_node(&mut nodes, &pe.to_token);
180            }
181
182            // Track nodes in current subgraph
183            if let Some(ref mut sg) = current_subgraph {
184                for pe in &parsed_edges {
185                    if !sg.node_ids.contains(&pe.from) {
186                        sg.node_ids.push(pe.from.clone());
187                    }
188                    if !sg.node_ids.contains(&pe.to) {
189                        sg.node_ids.push(pe.to.clone());
190                    }
191                }
192            }
193
194            edges.extend(parsed_edges);
195            continue;
196        }
197
198        // Try to parse as standalone node definition
199        if let Some(node) = try_parse_node_def(trimmed) {
200            if let Some(ref mut sg) = current_subgraph
201                && !sg.node_ids.contains(&node.id)
202            {
203                sg.node_ids.push(node.id.clone());
204            }
205            nodes.insert(node.id.clone(), node);
206            continue;
207        }
208
209        // Unknown line — skip silently (lenient parsing)
210    }
211
212    // Close any unclosed subgraph
213    if let Some(sg) = current_subgraph.take() {
214        subgraphs.push(sg);
215    }
216
217    // Build the SceneGraph
218    build_scene_graph(&nodes, &edges, &subgraphs, direction)
219}
220
221/// Parse direction from the header line.
222fn parse_direction(header: &str) -> FlowDirection {
223    let parts: Vec<&str> = header.split_whitespace().collect();
224    match parts.get(1).map(|s| s.to_uppercase()).as_deref() {
225        Some("TD") | Some("TB") => FlowDirection::TopDown,
226        Some("LR") => FlowDirection::LeftRight,
227        Some("RL") => FlowDirection::RightLeft,
228        Some("BT") => FlowDirection::BottomUp,
229        _ => FlowDirection::TopDown,
230    }
231}
232
233/// Parse a subgraph header: `id[label]` or `id ["label"]` → (id, label).
234fn parse_subgraph_header(rest: &str) -> Option<(String, String)> {
235    // Try `id[label]` format
236    if let Some(bracket_start) = rest.find('[') {
237        let id = rest[..bracket_start].trim().to_string();
238        let after = &rest[bracket_start + 1..];
239        let label = after
240            .trim_end_matches(']')
241            .trim()
242            .trim_matches('"')
243            .to_string();
244        if !id.is_empty() {
245            return Some((sanitize_id(&id), label));
246        }
247    }
248    None
249}
250
251/// Sanitize a string into a valid FD node ID (alphanumeric + underscore).
252fn sanitize_id(s: &str) -> String {
253    s.trim()
254        .chars()
255        .map(|c| {
256            if c.is_alphanumeric() || c == '_' {
257                c
258            } else {
259                '_'
260            }
261        })
262        .collect::<String>()
263        .trim_matches('_')
264        .to_string()
265}
266
267/// Ensure a node exists in the map. If not, create one from the token.
268/// `token` may be a bare ID (`A`) or a shaped token (`A[Label]`, `A(Round)`).
269/// The HashMap key is always the bare ID.
270fn ensure_node(nodes: &mut HashMap<String, MermaidNode>, token: &str) {
271    let bare_id = extract_node_id(token);
272    if nodes.contains_key(&bare_id) {
273        return;
274    }
275    // Check if the token has inline shape definition, e.g. "A[Label]"
276    if let Some(node) = try_parse_node_def(token) {
277        nodes.insert(node.id.clone(), node);
278    } else {
279        nodes.insert(
280            bare_id.clone(),
281            MermaidNode {
282                id: bare_id.clone(),
283                label: bare_id,
284                shape: MermaidNodeShape::Rect,
285            },
286        );
287    }
288}
289
290/// Try to parse a standalone node definition like `A[Label]`, `B(Round)`, `C((Circle))`.
291fn try_parse_node_def(s: &str) -> Option<MermaidNode> {
292    let s = s.trim().trim_end_matches(';');
293
294    // Extract ID (leading alphanumeric/underscore chars)
295    let id_end = s
296        .find(|c: char| !c.is_alphanumeric() && c != '_')
297        .unwrap_or(s.len());
298    if id_end == 0 {
299        return None;
300    }
301    let id = &s[..id_end];
302    let rest = &s[id_end..];
303
304    if rest.is_empty() {
305        // Bare ID, no shape — default rect with label = id
306        return Some(MermaidNode {
307            id: id.to_string(),
308            label: id.to_string(),
309            shape: MermaidNodeShape::Rect,
310        });
311    }
312
313    // Determine shape by delimiter
314    let (shape, label) = if rest.starts_with("((") && rest.ends_with("))") {
315        // Circle: A((Label))
316        let inner = &rest[2..rest.len() - 2];
317        (MermaidNodeShape::Circle, inner.trim().to_string())
318    } else if rest.starts_with('(') && rest.ends_with(')') {
319        // Rounded: A(Label)
320        let inner = &rest[1..rest.len() - 1];
321        (MermaidNodeShape::Rounded, inner.trim().to_string())
322    } else if rest.starts_with('[') && rest.ends_with(']') {
323        // Rect: A[Label]
324        let inner = &rest[1..rest.len() - 1];
325        (MermaidNodeShape::Rect, inner.trim().to_string())
326    } else if rest.starts_with('{') && rest.ends_with('}') {
327        // Diamond: A{Label}
328        let inner = &rest[1..rest.len() - 1];
329        (MermaidNodeShape::Diamond, inner.trim().to_string())
330    } else if rest.starts_with('>') && rest.ends_with(']') {
331        // Flag: A>Label]
332        let inner = &rest[1..rest.len() - 1];
333        (MermaidNodeShape::Flag, inner.trim().to_string())
334    } else {
335        return None;
336    };
337
338    // Strip quotes from label if present
339    let label = label.trim_matches('"').to_string();
340
341    Some(MermaidNode {
342        id: id.to_string(),
343        label,
344        shape,
345    })
346}
347
348/// Try to parse an edge line like `A --> B`, `A -->|text| B`, `A --- B`.
349/// Handles chained edges: `A --> B --> C` produces two edges.
350fn try_parse_edge_line(line: &str) -> Option<Vec<MermaidEdge>> {
351    let line = line.trim().trim_end_matches(';');
352
353    // Edge patterns to detect (ordered by specificity)
354    let edge_patterns = [
355        ("-.->", true),  // dotted arrow
356        ("--->", true),  // thick arrow
357        ("-->", true),   // standard arrow
358        ("---", false),  // no arrow
359        ("==>", true),   // thick arrow
360        ("===", false),  // thick line
361        ("-..-", false), // dotted line
362        ("-.-", false),  // dotted line
363        ("->", true),    // short arrow
364    ];
365
366    // Find the first edge pattern in the line
367    let mut edges = Vec::new();
368    let mut remaining = line.to_string();
369
370    loop {
371        let mut found = false;
372
373        for &(pattern, has_arrow) in &edge_patterns {
374            if let Some(pos) = find_edge_pattern(&remaining, pattern) {
375                let left = remaining[..pos].trim();
376                let right_start = pos + pattern.len();
377                let right_part = &remaining[right_start..];
378
379                // Check for label: `|text|`
380                let (label, after_label) = extract_edge_label(right_part);
381                let right = extract_first_node(after_label.trim());
382
383                if left.is_empty() || right.is_empty() {
384                    break;
385                }
386
387                // The left side might be a node definition like `A[Label]`
388                let from_id = extract_node_id(left);
389                let to_id = extract_node_id(&right);
390
391                edges.push(MermaidEdge {
392                    from: from_id,
393                    to: to_id,
394                    from_token: left.to_string(),
395                    to_token: right.clone(),
396                    label,
397                    has_arrow,
398                });
399
400                // Continue with the rest for chained edges
401                let consumed =
402                    pos + pattern.len() + (right_part.len() - after_label.len()) + right.len();
403                if consumed < remaining.len() {
404                    remaining = after_label[right.len()..].to_string();
405                } else {
406                    remaining.clear();
407                }
408                found = true;
409                break;
410            }
411        }
412
413        if !found || remaining.trim().is_empty() {
414            break;
415        }
416    }
417
418    if edges.is_empty() { None } else { Some(edges) }
419}
420
421/// Find an edge pattern, making sure it's not inside brackets.
422fn find_edge_pattern(s: &str, pattern: &str) -> Option<usize> {
423    let mut depth_sq = 0i32;
424    let mut depth_paren = 0i32;
425    let mut depth_curly = 0i32;
426    let bytes = s.as_bytes();
427    let pat_bytes = pattern.as_bytes();
428
429    if pat_bytes.len() > bytes.len() {
430        return None;
431    }
432
433    for i in 0..=bytes.len() - pat_bytes.len() {
434        match bytes[i] {
435            b'[' => depth_sq += 1,
436            b']' => depth_sq -= 1,
437            b'(' => depth_paren += 1,
438            b')' => depth_paren -= 1,
439            b'{' => depth_curly += 1,
440            b'}' => depth_curly -= 1,
441            _ => {}
442        }
443
444        if depth_sq == 0
445            && depth_paren == 0
446            && depth_curly == 0
447            && &bytes[i..i + pat_bytes.len()] == pat_bytes
448        {
449            return Some(i);
450        }
451    }
452    None
453}
454
455/// Extract an optional edge label `|text|` from after the edge arrow.
456fn extract_edge_label(s: &str) -> (Option<String>, &str) {
457    let s = s.trim();
458    if let Some(after_pipe) = s.strip_prefix('|')
459        && let Some(end) = after_pipe.find('|')
460    {
461        let label = after_pipe[..end].trim().to_string();
462        let rest = &after_pipe[end + 1..];
463        return (Some(label), rest);
464    }
465    (None, s)
466}
467
468/// Extract the first node token (ID + optional shape brackets) from a string.
469fn extract_first_node(s: &str) -> String {
470    let s = s.trim();
471    // Read the ID part
472    let id_end = s
473        .find(|c: char| !c.is_alphanumeric() && c != '_')
474        .unwrap_or(s.len());
475    if id_end == 0 {
476        return s.to_string();
477    }
478
479    let rest = &s[id_end..];
480
481    // Check for shape brackets following the ID
482    let extra = if rest.starts_with("((") {
483        // Circle: find matching ))
484        rest.find("))").map(|p| p + 2).unwrap_or(0)
485    } else if rest.starts_with('(') {
486        rest.find(')').map(|p| p + 1).unwrap_or(0)
487    } else if rest.starts_with('[') {
488        rest.find(']').map(|p| p + 1).unwrap_or(0)
489    } else if rest.starts_with('{') {
490        rest.find('}').map(|p| p + 1).unwrap_or(0)
491    } else if rest.starts_with('>') {
492        rest.find(']').map(|p| p + 1).unwrap_or(0)
493    } else {
494        0
495    };
496
497    s[..id_end + extra].to_string()
498}
499
500/// Extract just the node ID from a token like `A[Label]` or bare `A`.
501fn extract_node_id(token: &str) -> String {
502    let token = token.trim();
503    let id_end = token
504        .find(|c: char| !c.is_alphanumeric() && c != '_')
505        .unwrap_or(token.len());
506    token[..id_end].to_string()
507}
508
509/// Build an FD SceneGraph from parsed Mermaid components.
510fn build_scene_graph(
511    nodes: &HashMap<String, MermaidNode>,
512    edges: &[MermaidEdge],
513    subgraphs: &[MermaidSubgraph],
514    direction: FlowDirection,
515) -> Result<SceneGraph, String> {
516    let mut graph = SceneGraph::new();
517    let root = graph.root;
518
519    // Collect nodes that belong to subgraphs
520    let mut subgraph_membership: HashMap<String, String> = HashMap::new();
521    for sg in subgraphs {
522        for nid in &sg.node_ids {
523            subgraph_membership.insert(nid.clone(), sg.id.clone());
524        }
525    }
526
527    // Create subgraph frames first
528    let mut subgraph_indices: HashMap<String, petgraph::graph::NodeIndex> = HashMap::new();
529    for (i, sg) in subgraphs.iter().enumerate() {
530        let sg_node_id = NodeId::intern(&sanitize_id(&sg.id));
531        let frame_node = SceneNode {
532            id: sg_node_id,
533            kind: NodeKind::Frame {
534                width: 300.0,
535                height: 200.0,
536                clip: false,
537                layout: LayoutMode::Free { pad: 0.0 },
538            },
539            props: Properties {
540                fill: Some(Paint::Solid(Color::rgba(0.95, 0.95, 0.97, 1.0))),
541                corner_radius: Some(12.0),
542                stroke: Some(Stroke {
543                    paint: Paint::Solid(Color::rgba(0.7, 0.7, 0.8, 1.0)),
544                    width: 1.5,
545                    cap: StrokeCap::Round,
546                    join: StrokeJoin::Round,
547                }),
548                ..Properties::default()
549            },
550            use_styles: Default::default(),
551            constraints: smallvec::smallvec![Constraint::Position {
552                x: 50.0 + (i as f32) * 350.0,
553                y: 50.0,
554            }],
555            animations: Default::default(),
556            spec: None,
557            comments: vec![format!("Subgraph: {}", sg.label)],
558            place: None,
559            locked: false,
560        };
561        let idx = graph.add_node(root, frame_node);
562        subgraph_indices.insert(sg.id.clone(), idx);
563    }
564
565    // Create FD nodes with auto-layout positioning
566    let node_count = nodes.len();
567    let cols = match direction {
568        FlowDirection::TopDown | FlowDirection::BottomUp => {
569            (node_count as f32).sqrt().ceil() as usize
570        }
571        FlowDirection::LeftRight | FlowDirection::RightLeft => node_count,
572    };
573    let spacing_x = 200.0_f32;
574    let spacing_y = 150.0_f32;
575
576    // Sort nodes by first occurrence in edges for consistent ordering
577    let mut ordered_ids: Vec<String> = Vec::new();
578    for edge in edges {
579        if !ordered_ids.contains(&edge.from) {
580            ordered_ids.push(edge.from.clone());
581        }
582        if !ordered_ids.contains(&edge.to) {
583            ordered_ids.push(edge.to.clone());
584        }
585    }
586    // Add any nodes not referenced in edges
587    for id in nodes.keys() {
588        if !ordered_ids.contains(id) {
589            ordered_ids.push(id.clone());
590        }
591    }
592
593    let mut node_id_map: HashMap<String, NodeId> = HashMap::new();
594
595    for (i, mermaid_id) in ordered_ids.iter().enumerate() {
596        let mnode = match nodes.get(mermaid_id) {
597            Some(n) => n,
598            None => continue,
599        };
600
601        let fd_id = NodeId::intern(&sanitize_id(&mnode.id));
602        node_id_map.insert(mermaid_id.clone(), fd_id);
603
604        let col = i % cols.max(1);
605        let row = i / cols.max(1);
606        let rel_x = col as f32 * spacing_x;
607        let rel_y = row as f32 * spacing_y;
608
609        // Determine parent (subgraph frame or root)
610        let parent_idx = subgraph_membership
611            .get(mermaid_id)
612            .and_then(|sg_id| subgraph_indices.get(sg_id))
613            .copied()
614            .unwrap_or(root);
615
616        // Create the node based on shape
617        let (kind, corner_radius) = match mnode.shape {
618            MermaidNodeShape::Rect | MermaidNodeShape::Flag => (
619                NodeKind::Rect {
620                    width: 120.0,
621                    height: 60.0,
622                },
623                Some(8.0),
624            ),
625            MermaidNodeShape::Rounded => (
626                NodeKind::Rect {
627                    width: 120.0,
628                    height: 60.0,
629                },
630                Some(30.0),
631            ),
632            MermaidNodeShape::Circle => (NodeKind::Ellipse { rx: 40.0, ry: 40.0 }, None),
633            MermaidNodeShape::Diamond => (
634                NodeKind::Rect {
635                    width: 100.0,
636                    height: 100.0,
637                },
638                Some(4.0),
639            ),
640        };
641
642        let scene_node = SceneNode {
643            id: fd_id,
644            kind,
645            props: Properties {
646                fill: Some(Paint::Solid(Color::rgba(0.93, 0.95, 1.0, 1.0))),
647                stroke: Some(Stroke {
648                    paint: Paint::Solid(Color::rgba(0.2, 0.2, 0.3, 1.0)),
649                    width: 2.0,
650                    cap: StrokeCap::Round,
651                    join: StrokeJoin::Round,
652                }),
653                corner_radius,
654                ..Properties::default()
655            },
656            use_styles: Default::default(),
657            constraints: smallvec::smallvec![Constraint::Position { x: rel_x, y: rel_y }],
658            animations: Default::default(),
659            spec: None,
660            comments: Vec::new(),
661            place: None,
662            locked: false,
663        };
664
665        // Add the main node
666        let node_idx = graph.add_node(parent_idx, scene_node);
667
668        // Add text child for the label (if label differs from ID or has content)
669        if !mnode.label.is_empty() {
670            let text_id = NodeId::intern(&format!("{}_label", sanitize_id(&mnode.id)));
671            let text_node = SceneNode {
672                id: text_id,
673                kind: NodeKind::Text {
674                    content: mnode.label.clone(),
675                    max_width: None,
676                },
677                props: Properties {
678                    font: Some(FontSpec {
679                        family: "Inter".into(),
680                        weight: 500,
681                        size: 14.0,
682                    }),
683                    fill: Some(Paint::Solid(Color::rgba(0.1, 0.1, 0.15, 1.0))),
684                    ..Properties::default()
685                },
686                use_styles: Default::default(),
687                constraints: Default::default(),
688                animations: Default::default(),
689                spec: None,
690                comments: Vec::new(),
691                place: Some((HPlace::Center, VPlace::Middle)),
692                locked: false,
693            };
694            graph.add_node(node_idx, text_node);
695        }
696    }
697
698    // Create FD edges
699    for me in edges {
700        let from_id = match node_id_map.get(&me.from) {
701            Some(id) => *id,
702            None => continue,
703        };
704        let to_id = match node_id_map.get(&me.to) {
705            Some(id) => *id,
706            None => continue,
707        };
708
709        let edge_id = NodeId::intern(&format!(
710            "{}_to_{}",
711            sanitize_id(&me.from),
712            sanitize_id(&me.to)
713        ));
714
715        let arrow = if me.has_arrow {
716            ArrowKind::End
717        } else {
718            ArrowKind::None
719        };
720
721        // Create text child for edge label if present
722        let text_child = me.label.as_ref().map(|label_text| {
723            let tc_id = NodeId::intern(&format!("{}_text", edge_id.as_str()));
724            let text_node = SceneNode {
725                id: tc_id,
726                kind: NodeKind::Text {
727                    content: label_text.clone(),
728                    max_width: None,
729                },
730                props: Properties {
731                    font: Some(FontSpec {
732                        family: "Inter".into(),
733                        weight: 400,
734                        size: 12.0,
735                    }),
736                    fill: Some(Paint::Solid(Color::rgba(0.3, 0.3, 0.4, 1.0))),
737                    ..Properties::default()
738                },
739                use_styles: Default::default(),
740                constraints: Default::default(),
741                animations: Default::default(),
742                spec: None,
743                comments: Vec::new(),
744                place: None,
745                locked: false,
746            };
747            let idx = graph.graph.add_node(text_node);
748            graph.graph.add_edge(root, idx, ());
749            graph.id_index.insert(tc_id, idx);
750            tc_id
751        });
752
753        let edge = Edge {
754            id: edge_id,
755            from: EdgeAnchor::Node(from_id),
756            to: EdgeAnchor::Node(to_id),
757            text_child,
758            props: Properties::default(),
759            use_styles: Default::default(),
760            arrow,
761            curve: CurveKind::Smooth,
762            spec: None,
763            animations: Default::default(),
764            flow: None,
765            label_offset: None,
766        };
767        graph.edges.push(edge);
768    }
769
770    Ok(graph)
771}
772
773// ─── Tests ───────────────────────────────────────────────────────────────
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778
779    #[test]
780    fn parse_simple_flowchart() {
781        let input = "flowchart TD\n    A[Start] --> B[End]";
782        let graph = parse_mermaid(input).unwrap();
783        assert!(graph.get_by_id(NodeId::intern("A")).is_some());
784        assert!(graph.get_by_id(NodeId::intern("B")).is_some());
785        assert_eq!(graph.edges.len(), 1);
786        assert_eq!(graph.edges[0].arrow, ArrowKind::End);
787    }
788
789    #[test]
790    fn parse_flowchart_lr() {
791        let input = "flowchart LR\n    X[Hello] --> Y[World]";
792        let graph = parse_mermaid(input).unwrap();
793        assert!(graph.get_by_id(NodeId::intern("X")).is_some());
794        assert!(graph.get_by_id(NodeId::intern("Y")).is_some());
795    }
796
797    #[test]
798    fn parse_labeled_edge() {
799        let input = "flowchart TD\n    A --> |yes| B\n    A --> |no| C";
800        let graph = parse_mermaid(input).unwrap();
801        assert_eq!(graph.edges.len(), 2);
802        // Labels are stored as text_child nodes on the edge
803        assert!(graph.edges[0].text_child.is_some());
804        assert!(graph.edges[1].text_child.is_some());
805    }
806
807    #[test]
808    fn parse_rounded_node() {
809        let input = "flowchart TD\n    A(Rounded Node) --> B[Square]";
810        let graph = parse_mermaid(input).unwrap();
811        let a = graph.get_by_id(NodeId::intern("A")).unwrap();
812        // Rounded gets corner_radius=30
813        assert_eq!(a.props.corner_radius, Some(30.0));
814    }
815
816    #[test]
817    fn parse_circle_node() {
818        let input = "flowchart TD\n    A((Circle)) --> B[Rect]";
819        let graph = parse_mermaid(input).unwrap();
820        let a = graph.get_by_id(NodeId::intern("A")).unwrap();
821        assert!(matches!(a.kind, NodeKind::Ellipse { .. }));
822    }
823
824    #[test]
825    fn parse_no_arrow_edge() {
826        let input = "flowchart TD\n    A --- B";
827        let graph = parse_mermaid(input).unwrap();
828        assert_eq!(graph.edges.len(), 1);
829        assert_eq!(graph.edges[0].arrow, ArrowKind::None);
830    }
831
832    #[test]
833    fn parse_subgraph() {
834        let input = "flowchart TD\n    subgraph Frontend\n        A[React] --> B[Redux]\n    end\n    C[API]";
835        let graph = parse_mermaid(input).unwrap();
836        // Subgraph creates a Frame node
837        assert!(graph.get_by_id(NodeId::intern("Frontend")).is_some());
838        let frame = graph.get_by_id(NodeId::intern("Frontend")).unwrap();
839        assert!(matches!(frame.kind, NodeKind::Frame { .. }));
840        // All nodes should exist
841        assert!(graph.get_by_id(NodeId::intern("A")).is_some());
842        assert!(graph.get_by_id(NodeId::intern("B")).is_some());
843        assert!(graph.get_by_id(NodeId::intern("C")).is_some());
844    }
845
846    #[test]
847    fn parse_empty_input() {
848        let graph = parse_mermaid("").unwrap();
849        assert_eq!(graph.children(graph.root).len(), 0);
850    }
851
852    #[test]
853    fn parse_unsupported_type_errors() {
854        assert!(parse_mermaid("sequenceDiagram").is_err());
855        assert!(parse_mermaid("stateDiagram").is_err());
856        assert!(parse_mermaid("unknown").is_err());
857    }
858
859    #[test]
860    fn parse_graph_keyword() {
861        // `graph` is a synonym for `flowchart` in Mermaid
862        let input = "graph TD\n    A --> B";
863        let graph = parse_mermaid(input).unwrap();
864        assert!(graph.get_by_id(NodeId::intern("A")).is_some());
865    }
866
867    #[test]
868    fn parse_multiple_edges() {
869        let input = "flowchart TD\n    A --> B\n    B --> C\n    C --> A";
870        let graph = parse_mermaid(input).unwrap();
871        assert_eq!(graph.edges.len(), 3);
872        assert_eq!(graph.children(graph.root).len(), 3); // 3 nodes
873    }
874
875    #[test]
876    fn roundtrip_mermaid_to_fd() {
877        let input = "flowchart TD\n    A[Login] --> B[Dashboard]";
878        let graph = parse_mermaid(input).unwrap();
879        // Emit as FD text
880        let fd_text = crate::emitter::emit_document(&graph);
881        // Re-parse FD text
882        let reparsed = crate::parser::parse_document(&fd_text).unwrap();
883        assert!(reparsed.get_by_id(NodeId::intern("A")).is_some());
884        assert!(reparsed.get_by_id(NodeId::intern("B")).is_some());
885        assert!(!reparsed.edges.is_empty());
886    }
887
888    #[test]
889    fn parse_diamond_node() {
890        let input = "flowchart TD\n    A{Decision} --> B[Yes]";
891        let graph = parse_mermaid(input).unwrap();
892        let a = graph.get_by_id(NodeId::intern("A")).unwrap();
893        assert!(matches!(a.kind, NodeKind::Rect { .. }));
894    }
895
896    #[test]
897    fn parse_comments_and_empty_lines() {
898        let input = "flowchart TD\n    %% This is a comment\n\n    A --> B\n    %% Another comment";
899        let graph = parse_mermaid(input).unwrap();
900        assert!(graph.get_by_id(NodeId::intern("A")).is_some());
901        assert!(graph.get_by_id(NodeId::intern("B")).is_some());
902    }
903
904    #[test]
905    fn node_id_sanitization() {
906        assert_eq!(sanitize_id("hello-world"), "hello_world");
907        assert_eq!(sanitize_id("  spaces  "), "spaces");
908        assert_eq!(sanitize_id("valid_id"), "valid_id");
909    }
910
911    #[test]
912    fn extract_node_id_from_token() {
913        assert_eq!(extract_node_id("A[Label]"), "A");
914        assert_eq!(extract_node_id("myNode"), "myNode");
915        assert_eq!(extract_node_id("A((Circle))"), "A");
916    }
917}