1use crate::id::NodeId;
13use crate::model::*;
14use std::collections::HashMap;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum FlowDirection {
19 #[default]
20 TopDown,
21 LeftRight,
22 BottomUp,
23 RightLeft,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28enum MermaidNodeShape {
29 Rect,
31 Rounded,
33 Circle,
35 Diamond,
37 Flag,
39}
40
41#[derive(Debug, Clone)]
43struct MermaidNode {
44 id: String,
45 label: String,
46 shape: MermaidNodeShape,
47}
48
49#[derive(Debug, Clone)]
51struct MermaidEdge {
52 from: String,
53 to: String,
54 from_token: String,
56 to_token: String,
58 label: Option<String>,
59 has_arrow: bool,
60}
61
62#[derive(Debug, Clone)]
64struct MermaidSubgraph {
65 id: String,
66 label: String,
67 node_ids: Vec<String>,
68}
69
70pub 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 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
106fn parse_flowchart(input: &str) -> Result<SceneGraph, String> {
108 let mut lines = input.lines();
109
110 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 if trimmed.is_empty() || trimmed.starts_with("%%") {
124 continue;
125 }
126
127 if trimmed.starts_with("subgraph") {
129 let rest = trimmed.strip_prefix("subgraph").unwrap_or("").trim();
130 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 if trimmed == "end" {
147 if let Some(sg) = current_subgraph.take() {
148 subgraphs.push(sg);
149 }
150 continue;
151 }
152
153 if trimmed.starts_with("direction ") {
155 continue;
156 }
157
158 if trimmed.starts_with("style ") || trimmed.starts_with("classDef ") {
160 continue;
161 }
162
163 if trimmed.starts_with("class ") {
165 continue;
166 }
167
168 if trimmed.starts_with("click ") {
170 continue;
171 }
172
173 if let Some(parsed_edges) = try_parse_edge_line(trimmed) {
175 for pe in &parsed_edges {
176 ensure_node(&mut nodes, &pe.from_token);
179 ensure_node(&mut nodes, &pe.to_token);
180 }
181
182 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 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 }
211
212 if let Some(sg) = current_subgraph.take() {
214 subgraphs.push(sg);
215 }
216
217 build_scene_graph(&nodes, &edges, &subgraphs, direction)
219}
220
221fn 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
233fn parse_subgraph_header(rest: &str) -> Option<(String, String)> {
235 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
251fn 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
267fn 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 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
290fn try_parse_node_def(s: &str) -> Option<MermaidNode> {
292 let s = s.trim().trim_end_matches(';');
293
294 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 return Some(MermaidNode {
307 id: id.to_string(),
308 label: id.to_string(),
309 shape: MermaidNodeShape::Rect,
310 });
311 }
312
313 let (shape, label) = if rest.starts_with("((") && rest.ends_with("))") {
315 let inner = &rest[2..rest.len() - 2];
317 (MermaidNodeShape::Circle, inner.trim().to_string())
318 } else if rest.starts_with('(') && rest.ends_with(')') {
319 let inner = &rest[1..rest.len() - 1];
321 (MermaidNodeShape::Rounded, inner.trim().to_string())
322 } else if rest.starts_with('[') && rest.ends_with(']') {
323 let inner = &rest[1..rest.len() - 1];
325 (MermaidNodeShape::Rect, inner.trim().to_string())
326 } else if rest.starts_with('{') && rest.ends_with('}') {
327 let inner = &rest[1..rest.len() - 1];
329 (MermaidNodeShape::Diamond, inner.trim().to_string())
330 } else if rest.starts_with('>') && rest.ends_with(']') {
331 let inner = &rest[1..rest.len() - 1];
333 (MermaidNodeShape::Flag, inner.trim().to_string())
334 } else {
335 return None;
336 };
337
338 let label = label.trim_matches('"').to_string();
340
341 Some(MermaidNode {
342 id: id.to_string(),
343 label,
344 shape,
345 })
346}
347
348fn try_parse_edge_line(line: &str) -> Option<Vec<MermaidEdge>> {
351 let line = line.trim().trim_end_matches(';');
352
353 let edge_patterns = [
355 ("-.->", true), ("--->", true), ("-->", true), ("---", false), ("==>", true), ("===", false), ("-..-", false), ("-.-", false), ("->", true), ];
365
366 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 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 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 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
421fn 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
455fn 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
468fn extract_first_node(s: &str) -> String {
470 let s = s.trim();
471 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 let extra = if rest.starts_with("((") {
483 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
500fn 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
509fn 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 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 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 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 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 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 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 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 let node_idx = graph.add_node(parent_idx, scene_node);
667
668 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 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 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#[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 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 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 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 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 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); }
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 let fd_text = crate::emitter::emit_document(&graph);
881 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}