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 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 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 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 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 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 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 let node_idx = graph.add_node(parent_idx, scene_node);
665
666 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 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 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#[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 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 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 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 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 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); }
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 let fd_text = crate::emitter::emit_document(&graph);
877 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}