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            annotations: Vec::new(),
557            comments: vec![format!("Subgraph: {}", sg.label)],
558            place: None,
559        };
560        let idx = graph.add_node(root, frame_node);
561        subgraph_indices.insert(sg.id.clone(), idx);
562    }
563
564    // Create FD nodes with auto-layout positioning
565    let node_count = nodes.len();
566    let cols = match direction {
567        FlowDirection::TopDown | FlowDirection::BottomUp => {
568            (node_count as f32).sqrt().ceil() as usize
569        }
570        FlowDirection::LeftRight | FlowDirection::RightLeft => node_count,
571    };
572    let spacing_x = 200.0_f32;
573    let spacing_y = 150.0_f32;
574
575    // Sort nodes by first occurrence in edges for consistent ordering
576    let mut ordered_ids: Vec<String> = Vec::new();
577    for edge in edges {
578        if !ordered_ids.contains(&edge.from) {
579            ordered_ids.push(edge.from.clone());
580        }
581        if !ordered_ids.contains(&edge.to) {
582            ordered_ids.push(edge.to.clone());
583        }
584    }
585    // Add any nodes not referenced in edges
586    for id in nodes.keys() {
587        if !ordered_ids.contains(id) {
588            ordered_ids.push(id.clone());
589        }
590    }
591
592    let mut node_id_map: HashMap<String, NodeId> = HashMap::new();
593
594    for (i, mermaid_id) in ordered_ids.iter().enumerate() {
595        let mnode = match nodes.get(mermaid_id) {
596            Some(n) => n,
597            None => continue,
598        };
599
600        let fd_id = NodeId::intern(&sanitize_id(&mnode.id));
601        node_id_map.insert(mermaid_id.clone(), fd_id);
602
603        let col = i % cols.max(1);
604        let row = i / cols.max(1);
605        let rel_x = col as f32 * spacing_x;
606        let rel_y = row as f32 * spacing_y;
607
608        // Determine parent (subgraph frame or root)
609        let parent_idx = subgraph_membership
610            .get(mermaid_id)
611            .and_then(|sg_id| subgraph_indices.get(sg_id))
612            .copied()
613            .unwrap_or(root);
614
615        // Create the node based on shape
616        let (kind, corner_radius) = match mnode.shape {
617            MermaidNodeShape::Rect | MermaidNodeShape::Flag => (
618                NodeKind::Rect {
619                    width: 120.0,
620                    height: 60.0,
621                },
622                Some(8.0),
623            ),
624            MermaidNodeShape::Rounded => (
625                NodeKind::Rect {
626                    width: 120.0,
627                    height: 60.0,
628                },
629                Some(30.0),
630            ),
631            MermaidNodeShape::Circle => (NodeKind::Ellipse { rx: 40.0, ry: 40.0 }, None),
632            MermaidNodeShape::Diamond => (
633                NodeKind::Rect {
634                    width: 100.0,
635                    height: 100.0,
636                },
637                Some(4.0),
638            ),
639        };
640
641        let scene_node = SceneNode {
642            id: fd_id,
643            kind,
644            props: Properties {
645                fill: Some(Paint::Solid(Color::rgba(0.93, 0.95, 1.0, 1.0))),
646                stroke: Some(Stroke {
647                    paint: Paint::Solid(Color::rgba(0.2, 0.2, 0.3, 1.0)),
648                    width: 2.0,
649                    cap: StrokeCap::Round,
650                    join: StrokeJoin::Round,
651                }),
652                corner_radius,
653                ..Properties::default()
654            },
655            use_styles: Default::default(),
656            constraints: smallvec::smallvec![Constraint::Position { x: rel_x, y: rel_y }],
657            animations: Default::default(),
658            annotations: Vec::new(),
659            comments: Vec::new(),
660            place: None,
661        };
662
663        // Add the main node
664        let node_idx = graph.add_node(parent_idx, scene_node);
665
666        // Add text child for the label (if label differs from ID or has content)
667        if !mnode.label.is_empty() {
668            let text_id = NodeId::intern(&format!("{}_label", sanitize_id(&mnode.id)));
669            let text_node = SceneNode {
670                id: text_id,
671                kind: NodeKind::Text {
672                    content: mnode.label.clone(),
673                    max_width: None,
674                },
675                props: Properties {
676                    font: Some(FontSpec {
677                        family: "Inter".into(),
678                        weight: 500,
679                        size: 14.0,
680                    }),
681                    fill: Some(Paint::Solid(Color::rgba(0.1, 0.1, 0.15, 1.0))),
682                    ..Properties::default()
683                },
684                use_styles: Default::default(),
685                constraints: Default::default(),
686                animations: Default::default(),
687                annotations: Vec::new(),
688                comments: Vec::new(),
689                place: Some((HPlace::Center, VPlace::Middle)),
690            };
691            graph.add_node(node_idx, text_node);
692        }
693    }
694
695    // Create FD edges
696    for me in edges {
697        let from_id = match node_id_map.get(&me.from) {
698            Some(id) => *id,
699            None => continue,
700        };
701        let to_id = match node_id_map.get(&me.to) {
702            Some(id) => *id,
703            None => continue,
704        };
705
706        let edge_id = NodeId::intern(&format!(
707            "{}_to_{}",
708            sanitize_id(&me.from),
709            sanitize_id(&me.to)
710        ));
711
712        let arrow = if me.has_arrow {
713            ArrowKind::End
714        } else {
715            ArrowKind::None
716        };
717
718        // Create text child for edge label if present
719        let text_child = me.label.as_ref().map(|label_text| {
720            let tc_id = NodeId::intern(&format!("{}_text", edge_id.as_str()));
721            let text_node = SceneNode {
722                id: tc_id,
723                kind: NodeKind::Text {
724                    content: label_text.clone(),
725                    max_width: None,
726                },
727                props: Properties {
728                    font: Some(FontSpec {
729                        family: "Inter".into(),
730                        weight: 400,
731                        size: 12.0,
732                    }),
733                    fill: Some(Paint::Solid(Color::rgba(0.3, 0.3, 0.4, 1.0))),
734                    ..Properties::default()
735                },
736                use_styles: Default::default(),
737                constraints: Default::default(),
738                animations: Default::default(),
739                annotations: Vec::new(),
740                comments: Vec::new(),
741                place: None,
742            };
743            let idx = graph.graph.add_node(text_node);
744            graph.graph.add_edge(root, idx, ());
745            graph.id_index.insert(tc_id, idx);
746            tc_id
747        });
748
749        let edge = Edge {
750            id: edge_id,
751            from: EdgeAnchor::Node(from_id),
752            to: EdgeAnchor::Node(to_id),
753            text_child,
754            props: Properties::default(),
755            use_styles: Default::default(),
756            arrow,
757            curve: CurveKind::Smooth,
758            annotations: Vec::new(),
759            animations: Default::default(),
760            flow: None,
761            label_offset: None,
762        };
763        graph.edges.push(edge);
764    }
765
766    Ok(graph)
767}
768
769// ─── Tests ───────────────────────────────────────────────────────────────
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    #[test]
776    fn parse_simple_flowchart() {
777        let input = "flowchart TD\n    A[Start] --> B[End]";
778        let graph = parse_mermaid(input).unwrap();
779        assert!(graph.get_by_id(NodeId::intern("A")).is_some());
780        assert!(graph.get_by_id(NodeId::intern("B")).is_some());
781        assert_eq!(graph.edges.len(), 1);
782        assert_eq!(graph.edges[0].arrow, ArrowKind::End);
783    }
784
785    #[test]
786    fn parse_flowchart_lr() {
787        let input = "flowchart LR\n    X[Hello] --> Y[World]";
788        let graph = parse_mermaid(input).unwrap();
789        assert!(graph.get_by_id(NodeId::intern("X")).is_some());
790        assert!(graph.get_by_id(NodeId::intern("Y")).is_some());
791    }
792
793    #[test]
794    fn parse_labeled_edge() {
795        let input = "flowchart TD\n    A --> |yes| B\n    A --> |no| C";
796        let graph = parse_mermaid(input).unwrap();
797        assert_eq!(graph.edges.len(), 2);
798        // Labels are stored as text_child nodes on the edge
799        assert!(graph.edges[0].text_child.is_some());
800        assert!(graph.edges[1].text_child.is_some());
801    }
802
803    #[test]
804    fn parse_rounded_node() {
805        let input = "flowchart TD\n    A(Rounded Node) --> B[Square]";
806        let graph = parse_mermaid(input).unwrap();
807        let a = graph.get_by_id(NodeId::intern("A")).unwrap();
808        // Rounded gets corner_radius=30
809        assert_eq!(a.props.corner_radius, Some(30.0));
810    }
811
812    #[test]
813    fn parse_circle_node() {
814        let input = "flowchart TD\n    A((Circle)) --> B[Rect]";
815        let graph = parse_mermaid(input).unwrap();
816        let a = graph.get_by_id(NodeId::intern("A")).unwrap();
817        assert!(matches!(a.kind, NodeKind::Ellipse { .. }));
818    }
819
820    #[test]
821    fn parse_no_arrow_edge() {
822        let input = "flowchart TD\n    A --- B";
823        let graph = parse_mermaid(input).unwrap();
824        assert_eq!(graph.edges.len(), 1);
825        assert_eq!(graph.edges[0].arrow, ArrowKind::None);
826    }
827
828    #[test]
829    fn parse_subgraph() {
830        let input = "flowchart TD\n    subgraph Frontend\n        A[React] --> B[Redux]\n    end\n    C[API]";
831        let graph = parse_mermaid(input).unwrap();
832        // Subgraph creates a Frame node
833        assert!(graph.get_by_id(NodeId::intern("Frontend")).is_some());
834        let frame = graph.get_by_id(NodeId::intern("Frontend")).unwrap();
835        assert!(matches!(frame.kind, NodeKind::Frame { .. }));
836        // All nodes should exist
837        assert!(graph.get_by_id(NodeId::intern("A")).is_some());
838        assert!(graph.get_by_id(NodeId::intern("B")).is_some());
839        assert!(graph.get_by_id(NodeId::intern("C")).is_some());
840    }
841
842    #[test]
843    fn parse_empty_input() {
844        let graph = parse_mermaid("").unwrap();
845        assert_eq!(graph.children(graph.root).len(), 0);
846    }
847
848    #[test]
849    fn parse_unsupported_type_errors() {
850        assert!(parse_mermaid("sequenceDiagram").is_err());
851        assert!(parse_mermaid("stateDiagram").is_err());
852        assert!(parse_mermaid("unknown").is_err());
853    }
854
855    #[test]
856    fn parse_graph_keyword() {
857        // `graph` is a synonym for `flowchart` in Mermaid
858        let input = "graph TD\n    A --> B";
859        let graph = parse_mermaid(input).unwrap();
860        assert!(graph.get_by_id(NodeId::intern("A")).is_some());
861    }
862
863    #[test]
864    fn parse_multiple_edges() {
865        let input = "flowchart TD\n    A --> B\n    B --> C\n    C --> A";
866        let graph = parse_mermaid(input).unwrap();
867        assert_eq!(graph.edges.len(), 3);
868        assert_eq!(graph.children(graph.root).len(), 3); // 3 nodes
869    }
870
871    #[test]
872    fn roundtrip_mermaid_to_fd() {
873        let input = "flowchart TD\n    A[Login] --> B[Dashboard]";
874        let graph = parse_mermaid(input).unwrap();
875        // Emit as FD text
876        let fd_text = crate::emitter::emit_document(&graph);
877        // Re-parse FD text
878        let reparsed = crate::parser::parse_document(&fd_text).unwrap();
879        assert!(reparsed.get_by_id(NodeId::intern("A")).is_some());
880        assert!(reparsed.get_by_id(NodeId::intern("B")).is_some());
881        assert!(!reparsed.edges.is_empty());
882    }
883
884    #[test]
885    fn parse_diamond_node() {
886        let input = "flowchart TD\n    A{Decision} --> B[Yes]";
887        let graph = parse_mermaid(input).unwrap();
888        let a = graph.get_by_id(NodeId::intern("A")).unwrap();
889        assert!(matches!(a.kind, NodeKind::Rect { .. }));
890    }
891
892    #[test]
893    fn parse_comments_and_empty_lines() {
894        let input = "flowchart TD\n    %% This is a comment\n\n    A --> B\n    %% Another comment";
895        let graph = parse_mermaid(input).unwrap();
896        assert!(graph.get_by_id(NodeId::intern("A")).is_some());
897        assert!(graph.get_by_id(NodeId::intern("B")).is_some());
898    }
899
900    #[test]
901    fn node_id_sanitization() {
902        assert_eq!(sanitize_id("hello-world"), "hello_world");
903        assert_eq!(sanitize_id("  spaces  "), "spaces");
904        assert_eq!(sanitize_id("valid_id"), "valid_id");
905    }
906
907    #[test]
908    fn extract_node_id_from_token() {
909        assert_eq!(extract_node_id("A[Label]"), "A");
910        assert_eq!(extract_node_id("myNode"), "myNode");
911        assert_eq!(extract_node_id("A((Circle))"), "A");
912    }
913}