use std::collections::HashMap; use lazy_static::lazy_static; use regex::Captures; use regex::Regex; type FixFn = Box String + Send + Sync>; struct Issue { line_no: usize, #[allow(dead_code)] start: usize, #[allow(dead_code)] end: usize, #[allow(dead_code)] message: String, fix: Option, } impl Issue { fn new( line_no: usize, start: usize, end: usize, message: impl Into, fix: FixFn, ) -> Self { Self { line_no, start, end, message: message.into(), fix: Some(fix), } } } struct MermaidLinter { lines: Vec, } impl MermaidLinter { fn new(source: &str) -> Self { let lines = if source.is_empty() { Vec::new() } else { source .split('\n') .map(std::string::ToString::to_string) .collect() }; Self { lines } } fn lint(&mut self) -> Vec { let mut issues: Vec = Vec::new(); let mut in_pie = false; let mut in_sequence = false; let mut in_diagram = false; let mut pending_updates: HashMap = HashMap::new(); let mut lines_copy = self.lines.clone(); for (idx, line) in lines_copy.iter().enumerate() { let line_no = idx + 1; let trimmed = line.trim(); let lowered = trimmed.to_lowercase(); if lowered.starts_with("pie") { in_pie = true; in_diagram = true; } if lowered.starts_with("sequencediagram") { in_sequence = true; in_diagram = true; } else if lowered.starts_with("graph") || lowered.starts_with("flowchart") || lowered.starts_with("classdiagram") || lowered.starts_with("erdiagram") || lowered.starts_with("gantt") { in_sequence = false; in_pie = false; in_diagram = true; } if STYLE_RE.is_match(line) { issues.push(Issue::new( line_no, 0, line.len(), "Unsupported 'style' directive; removing line.", Box::new(|_| String::new()), )); continue; } for (pos, _) in line.match_indices('\t') { issues.push(Issue::new( line_no, pos, pos + 1, "Tab character found; use spaces instead.", Box::new(|line_text: &str| line_text.replacen('\t', " ", 1)), )); } if line.trim_end() != *line { let trimmed_line = line.trim_end().to_string(); issues.push(Issue::new( line_no, trimmed_line.len(), line.len(), "Trailing whitespace.", Box::new(move |_| trimmed_line.clone()), )); } if !in_sequence { let label_spans = compute_label_spans(line); for arrow in ARROW_RE.find_iter(line) { let start = arrow.start(); let end = arrow.end(); if label_spans.iter().any(|(a, b)| start >= *a && end <= *b) { continue; } if is_within_double_quotes(line, start, end) { continue; } if arrow.as_str() != "-->" { let message = format!("Inconsistent arrow style '{}'; use '-->'.", arrow.as_str()); issues.push(Issue::new( line_no, start, end, message, make_replace_span(start, end, "-->".to_string()), )); } } } if !in_diagram { for arrow in ARROW_RE.find_iter(line) { let start = arrow.start(); let end = arrow.end(); let lhs = &line[..start]; let rhs = &line[end..]; let left_tok = lhs .split_whitespace() .last() .map(str::to_string) .unwrap_or_default(); let right_tok = rhs .split_whitespace() .next() .map(str::to_string) .unwrap_or_default(); for node_tok in [left_tok, right_tok] { if node_tok.is_empty() || NODE_ID_VALID_RE.is_match(&node_tok) { continue; } if let Some(span_start) = line.rfind(&node_tok) { let span_end = span_start + node_tok.len(); let sanitized = sanitize_node_id(&node_tok); issues.push(Issue::new( line_no, span_start, span_end, "Node identifier should be lower_snake_case.", make_replace_span(span_start, span_end, sanitized), )); } } } } if in_sequence { let first_arrow = SEQ_ARROW_RE.find(line).or_else(|| ARROW_RE.find(line)); if let Some(arrow_match) = first_arrow { let mut before = line[..arrow_match.start()].to_string(); let mut after = line[arrow_match.end()..].to_string(); let arrow_text = arrow_match.as_str(); let mut changed = false; if let Some(caps) = SEQ_SENDER_UNDERSCORE_RE.captures(&before) && let (Some(group), Some(full)) = (caps.get(1), caps.get(0)) { let replacement = group.as_str().trim_end_matches('_').to_string(); let mut new_before = before[..group.start()].to_string(); new_before.push_str(&replacement); new_before.push_str(&before[full.end()..]); before = new_before; changed = true; } if let Some(caps) = SEQ_RECEIVER_UNDERSCORE_RE.captures(&after) { if let (Some(recv), Some(rest)) = (caps.get(2), caps.get(4)) { after = format!("{}: {}", recv.as_str(), rest.as_str().trim_start()); changed = true; } } else if let Some(caps) = SEQ_RECEIVER_MISSING_COLON_RE.captures(&after) && !after.trim_start().starts_with(':') && let (Some(recv), Some(rest)) = (caps.get(2), caps.get(4)) { after = format!("{}: {}", recv.as_str(), rest.as_str().trim_start()); changed = true; } if changed { let updated = format!("{before}{arrow_text}{after}"); pending_updates.insert(idx, updated); } } if let Some(arrow_match) = SEQ_ARROW_RE.find(line).or_else(|| ARROW_RE.find(line)) && let Some(rel_colon) = line[arrow_match.end()..].find(':') { let colon_pos = arrow_match.end() + rel_colon; if line[colon_pos + 1..].contains(';') { issues.push(Issue::new( line_no, colon_pos + 1, line.len(), "Semicolons in sequence message; use commas or split lines.", make_replace_after_colon(colon_pos, ';', ','), )); } } } if in_diagram && !in_pie && !in_sequence { for captures in SQUARE_LABEL_RE.captures_iter(line) { if let Some(span) = captures.get(1) { let raw = span.as_str(); if is_already_quoted(raw) { continue; } let start_idx = span.start(); let end_idx = span.end(); let replacement = format!("\"{}\"", raw.replace('"', "'")); issues.push(Issue::new( line_no, start_idx, end_idx, "Quote node label inside [] to allow punctuation.", make_replace_span(start_idx, end_idx, replacement), )); } } for captures in PAR2_LABEL_RE.captures_iter(line) { if let Some(mat) = captures.get(1) { if is_within_double_quotes(line, mat.start(), mat.end()) { continue; } let raw = mat.as_str(); if is_already_quoted(raw) { continue; } let start_idx = mat.start(); let end_idx = mat.end(); let replacement = format!("\"{}\"", raw.replace('"', "'")); issues.push(Issue::new( line_no, start_idx, end_idx, "Quote node label inside (( )) to allow punctuation.", make_replace_span(start_idx, end_idx, replacement), )); } } for captures in PAR1_LABEL_RE.captures_iter(line) { if let Some(mat) = captures.get(1) { if is_within_double_quotes(line, mat.start(), mat.end()) { continue; } let raw = mat.as_str(); if is_already_quoted(raw) { continue; } let start_idx = mat.start(); let end_idx = mat.end(); let replacement = format!("\"{}\"", raw.replace('"', "'")); issues.push(Issue::new( line_no, start_idx, end_idx, "Quote node label inside () to allow punctuation.", make_replace_span(start_idx, end_idx, replacement), )); } } } if in_pie && let Some(caps) = PIE_LINE_RE.captures(line) { let indent = caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string(); let label = caps.get(2).map(|m| m.as_str()).unwrap_or("").to_string(); let value = caps.get(3).map(|m| m.as_str()).unwrap_or("").to_string(); let mut fixed_label = label.clone(); for _ in 0..3 { let updated = PIE_INNER_QUOTE_RE .replace_all(&fixed_label, "$1$2$3") .into_owned(); if updated == fixed_label { break; } fixed_label = updated; } if fixed_label != label { let replacement_line = format!("{indent}\"{fixed_label}\": {value}"); issues.push(Issue::new( line_no, 0, line.len(), "Pie label contains quoted numeric count; removing inner quotes.", Box::new(move |_| replacement_line.clone()), )); } } if let Some(caps) = NODE_ID_RE.captures(line) && let Some(group) = caps.get(1) { let bad_id = group.as_str(); if !NODE_ID_VALID_RE.is_match(bad_id) { let sanitized = sanitize_node_id(bad_id); issues.push(Issue::new( line_no, group.start(), group.end(), "Node identifier should be lower_snake_case.", make_replace_span(group.start(), group.end(), sanitized), )); } } } for (idx, updated) in pending_updates { if idx < lines_copy.len() { lines_copy[idx] = updated; } } self.lines = lines_copy; issues } fn apply_fixes(&mut self, mut issues: Vec) -> usize { const MAX_PASSES: usize = 10; let mut passes = 0usize; while !issues.is_empty() && passes < MAX_PASSES { let remaining = self.apply_fixes_inner(&issues); passes += 1; if remaining == 0 { break; } issues = self.lint(); } passes } fn apply_fixes_inner(&mut self, issues: &[Issue]) -> usize { let mut issue_map: HashMap> = HashMap::new(); for issue in issues { issue_map.entry(issue.line_no).or_default().push(issue); } let mut new_lines: Vec = Vec::with_capacity(self.lines.len()); let mut unfixed = 0usize; for (idx, line) in self.lines.iter().enumerate() { let line_no = idx + 1; let Some(issues_on_line) = issue_map.get(&line_no) else { new_lines.push(line.clone()); continue; }; if issues_on_line.len() > 1 { let (first_issue, rest) = issues_on_line.split_first().expect("non-empty slice"); if let Some(fix) = &first_issue.fix { let fixed = fix(line); if !fixed.is_empty() { new_lines.push(fixed); } } else { unfixed += 1; new_lines.push(line.clone()); } unfixed += rest.len(); continue; } let issue = issues_on_line[0]; if let Some(fix) = &issue.fix { let fixed_line = fix(line); if !fixed_line.is_empty() { new_lines.push(fixed_line); } } else { unfixed += 1; new_lines.push(line.clone()); } } self.lines = new_lines; unfixed } } lazy_static! { static ref STYLE_RE: Regex = Regex::new(r"(?i)^\s*style\b").expect("valid regex"); static ref ARROW_RE: Regex = Regex::new(r"-{1,}[^-]*>").expect("valid regex"); static ref SEQ_ARROW_RE: Regex = Regex::new(r"-{1,2}(?:>>|>)").expect("valid regex"); static ref NODE_ID_RE: Regex = Regex::new(r"^\s*([a-zA-Z0-9_]+)\s*[\[(]").expect("valid regex"); static ref NODE_ID_VALID_RE: Regex = Regex::new(r"^[A-Za-z0-9_]+$").expect("valid regex"); static ref SQUARE_LABEL_RE: Regex = Regex::new(r"[A-Za-z0-9_]+\s*\[(.*?)\]").expect("valid regex"); static ref PAR2_LABEL_RE: Regex = Regex::new(r"[A-Za-z0-9_]+\s*\(\((.*?)\)\)").expect("valid regex"); static ref PAR1_LABEL_RE: Regex = Regex::new(r"[A-Za-z0-9_]+\s*\(([^()]*?)\)").expect("valid regex"); static ref SEQ_SENDER_UNDERSCORE_RE: Regex = Regex::new(r"([A-Za-z0-9_]+)_\s*$").expect("valid regex"); static ref SEQ_RECEIVER_UNDERSCORE_RE: Regex = Regex::new(r"^(\s*([A-Za-z0-9_]+))_(\s*)(.*)$").expect("valid regex"); static ref SEQ_RECEIVER_MISSING_COLON_RE: Regex = Regex::new(r"^(\s*([A-Za-z0-9_]+))(\s+)(.*)$").expect("valid regex"); static ref PIE_LINE_RE: Regex = Regex::new(r#"^(\s*)"(.+)"\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*$"#).expect("valid regex"); static ref PIE_INNER_QUOTE_RE: Regex = Regex::new(r#"([\(\[])\s*['"](\d+(?:\.\d+)?)['"]\s*([\)\]])"#).expect("valid regex"); static ref MERMAID_FENCE_RE: Regex = Regex::new(r"(?is)```mermaid(.*?)```").expect("valid regex"); static ref GENERIC_FENCE_RE: Regex = Regex::new(r"(?is)```([a-zA-Z0-9_+-]*)\n(.*?)```").expect("valid regex"); static ref HEADER_RE: Regex = Regex::new(r"(?i)^\s*(flowchart|graph|sequenceDiagram|classDiagram|erDiagram|gantt)\b") .expect("valid regex"); static ref HEADER_TITLE_SAME_LINE_RE: Regex = Regex::new( r"(?im)^(?P\s*)(?Pflowchart|graph)\s+(?PTB|TD|LR|RL|BT)\s+title\s+(?P.+)$", ) .expect("valid regex"); } fn sanitize_node_id(value: &str) -> String { let replaced = value .chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' } }) .collect::<String>(); replaced.to_lowercase() } fn make_replace_span(start: usize, end: usize, replacement: String) -> FixFn { Box::new(move |line: &str| { let mut result = String::with_capacity(line.len() - (end - start) + replacement.len()); result.push_str(&line[..start]); result.push_str(&replacement); result.push_str(&line[end..]); result }) } fn make_replace_after_colon(colon_pos: usize, find: char, replace_with: char) -> FixFn { Box::new(move |line: &str| { let mut result = String::with_capacity(line.len()); result.push_str(&line[..=colon_pos]); let tail = line[colon_pos + 1..].replace(find, &replace_with.to_string()); result.push_str(&tail); result }) } fn compute_label_spans(line: &str) -> Vec<(usize, usize)> { let mut spans: Vec<(usize, usize)> = Vec::new(); for caps in SQUARE_LABEL_RE.captures_iter(line) { if let Some(inner) = caps.get(1) { spans.push((inner.start(), inner.end())); } } for caps in PAR2_LABEL_RE.captures_iter(line) { if let Some(inner) = caps.get(1) { spans.push((inner.start(), inner.end())); } } for caps in PAR1_LABEL_RE.captures_iter(line) { if let Some(inner) = caps.get(1) { spans.push((inner.start(), inner.end())); } } spans } fn leading_indent(haystack: &str, m: ®ex::Match) -> String { let start = m.start(); let line_start = haystack[..start] .rfind('\n') .map(|idx| idx.saturating_add(1)) .unwrap_or(0); haystack[line_start..start] .chars() .take_while(|ch| ch.is_whitespace()) .collect() } fn indent_block(block: &str, indent: &str) -> String { if indent.is_empty() { return block.to_string(); } block .lines() .map(|line| { if line.is_empty() { indent.to_string() } else { format!("{indent}{line}") } }) .collect::<Vec<_>>() .join("\n") } fn is_already_quoted(raw: &str) -> bool { let trimmed = raw.trim(); trimmed.len() >= 2 && ((trimmed.starts_with('"') && trimmed.ends_with('"')) || (trimmed.starts_with('\'') && trimmed.ends_with('\''))) } fn is_within_double_quotes(line: &str, start: usize, end: usize) -> bool { let mut in_quote = false; let mut quote_start = 0usize; let mut spans: Vec<(usize, usize)> = Vec::new(); for (idx, ch) in line.char_indices() { if ch == '"' { if !in_quote { in_quote = true; quote_start = idx; } else { spans.push((quote_start, idx)); in_quote = false; } } } spans.iter().any(|(a, b)| start >= *a && end <= *b) } fn normalize_header_titles(source: &str) -> String { HEADER_TITLE_SAME_LINE_RE .replace_all(source, |caps: &Captures| { let indent = caps.name("indent").map(|m| m.as_str()).unwrap_or(""); let keyword = caps .name("keyword") .map(|m| m.as_str()) .unwrap_or("flowchart"); let dir = caps.name("dir").map(|m| m.as_str()).unwrap_or("TD"); let title = caps.name("title").map(|m| m.as_str()).unwrap_or(""); format!("{indent}{keyword} {dir}\n{indent} title {title}") }) .into_owned() } fn lint_and_wrap(code: &str) -> String { let normalized = normalize_header_titles(code); let mut linter = MermaidLinter::new(&normalized); let issues = linter.lint(); linter.apply_fixes(issues); let fixed = if linter.lines.is_empty() { String::new() } else { linter.lines.join("\n") }; format!("```mermaid\n{fixed}\n```") } pub(crate) fn fix_mermaid_blocks(input: &str) -> String { if input.trim().is_empty() { return input.to_string(); } let after_fenced = MERMAID_FENCE_RE .replace_all(input, |caps: &Captures| { let full_match = caps .get(0) .expect("full match is always present for fenced mermaid block"); let indent = leading_indent(input, &full_match); let body = caps .get(1) .map(|m| m.as_str()) .unwrap_or("") .trim_matches('\n'); let wrapped = lint_and_wrap(body); if indent.is_empty() { wrapped } else { indent_block(&wrapped, &indent) } }) .into_owned(); let after_generic = GENERIC_FENCE_RE .replace_all(&after_fenced, |caps: &Captures| { let full_match = caps .get(0) .expect("full match is always present for fenced code block"); let indent = leading_indent(&after_fenced, &full_match); let lang = caps .get(1) .map(|m| m.as_str()) .unwrap_or("") .trim() .to_lowercase(); if lang == "mermaid" { return caps .get(0) .map(|m| m.as_str().to_string()) .unwrap_or_default(); } let body = caps .get(2) .map(|m| m.as_str()) .unwrap_or("") .trim_matches('\n'); let head = body.lines().next().unwrap_or("").trim().to_lowercase(); if [ "flowchart", "graph", "sequencediagram", "classdiagram", "erdiagram", "gantt", ] .iter() .any(|prefix| head.starts_with(prefix)) { let wrapped = lint_and_wrap(body); if indent.is_empty() { wrapped } else { indent_block(&wrapped, &indent) } } else { caps.get(0) .map(|m| m.as_str().to_string()) .unwrap_or_default() } }) .into_owned(); let lines: Vec<String> = after_generic .split('\n') .map(std::string::ToString::to_string) .collect(); if lines.is_empty() { return after_generic; } let mut out_lines: Vec<String> = Vec::new(); let mut in_code_block = false; let mut idx = 0usize; while idx < lines.len() { let line = &lines[idx]; let trimmed = line.trim(); if trimmed.starts_with("```") { in_code_block = !in_code_block; out_lines.push(line.clone()); idx += 1; continue; } if !in_code_block && HEADER_RE.is_match(trimmed) { let start = idx; let mut end = idx; while end < lines.len() { let current = lines[end].trim(); if current.starts_with("```") || current.is_empty() { break; } end += 1; } let block = lines[start..end].join("\n"); out_lines.push(lint_and_wrap(block.trim_matches('\n'))); idx = end; // Always add a blank line separator after a mermaid block to avoid // back-to-back fenced blocks which some renderers mishandle. if idx < lines.len() { if lines[idx].trim().is_empty() { out_lines.push(lines[idx].clone()); idx += 1; } else { out_lines.push(String::new()); } } continue; } out_lines.push(line.clone()); idx += 1; } out_lines.join("\n") } #[cfg(test)] mod tests { use super::fix_mermaid_blocks; use pretty_assertions::assert_eq; use regex::Regex; #[test] fn flowchart_nodes_are_quoted_even_when_unfenced() { let raw = [ "flowchart LR", " A[Caller Service or SDK] --> B[ekm_client Encryptor]", " B --> C[KeyProviderDefault (HTTP)]", " C --> D[EKM FastAPI Service]", " D --> E[Provider Selector]", " E --> F[Cloud KMS (AWS/GCP/Azure)]", " B --> G[ekm_client_cpp V1Header build/parse]", " B --> H[Tink AEAD (streaming/non-streaming)]", ] .join("\n"); let fixed = fix_mermaid_blocks(&raw); assert!(fixed.contains(r#"A["Caller Service or SDK"]"#)); assert!(fixed.contains(r#"B["ekm_client Encryptor"]"#)); assert!(fixed.contains(r#"C["KeyProviderDefault (HTTP)"]"#)); assert!(fixed.contains(r#"D["EKM FastAPI Service"]"#)); assert!(fixed.contains(r#"E["Provider Selector"]"#)); assert!(fixed.contains(r#"F["Cloud KMS (AWS/GCP/Azure)"]"#)); assert!(fixed.contains(r#"G["ekm_client_cpp V1Header build/parse"]"#)); assert!(fixed.contains(r#"H["Tink AEAD (streaming/non-streaming)"]"#)); } #[test] fn sequence_semicolons_removed_in_messages() { let raw = "```mermaid\nsequenceDiagram\n Ingress->>Ingress: Sanitize logs; reject non-HTTPS with 403\n```"; let fixed = fix_mermaid_blocks(raw); let message_re = Regex::new(r"Ingress->>Ingress:\s*(.*)").unwrap(); let msg = message_re .captures(&fixed) .and_then(|caps| caps.get(1)) .map(|m| m.as_str()) .unwrap_or_default(); assert!(!msg.contains(';')); assert!(msg.contains(',')); } #[test] fn unfenced_pie_chart_is_not_wrapped() { let raw = r#"pie "High ("12")": 12 "Medium ("39")": 39 "Low ("20")": 20 "#; let fixed = fix_mermaid_blocks(raw); assert!(!fixed.contains("```mermaid")); } #[test] fn pie_chart_inner_quotes_removed_when_fenced() { let raw = r#"```mermaid pie "High ("12")": 12 "Medium ("39")": 39 "Low ("20")": 20 ```"#; let fixed = fix_mermaid_blocks(raw); assert!(fixed.contains(r#""High (12)": 12"#)); assert!(fixed.contains(r#""Medium (39)": 39"#)); assert!(fixed.contains(r#""Low (20)": 20"#)); } #[test] fn flowchart_paren_labels_are_quoted() { let raw = "```mermaid\nflowchart LR\n A((Start node)) --> B(Account)\n```"; let fixed = fix_mermaid_blocks(raw); assert!(fixed.contains(r#"A(("Start node"))"#)); assert!(fixed.contains(r#"B("Account")"#)); } #[test] fn graph_nodes_are_quoted_even_when_unfenced() { let raw = "graph LR\n A[Client App] --> B[API Server]\n B --> C[DB (primary)]\n"; let fixed = fix_mermaid_blocks(raw); assert!(fixed.contains(r#"A["Client App"]"#)); assert!(fixed.contains(r#"B["API Server"]"#)); assert!(fixed.contains(r#"C["DB (primary)"]"#)); } #[test] fn flowchart_quotes_and_preserves_inner_arrows() { let raw = [ "flowchart LR", " ClientApp[Client App - React] --> CalpicoState[Calpico State (signals + React Query)]", " CalpicoState --> APIServer[API Server (/api/calpico)]", " CalpicoState --> WebSocket[WebSocket Events]", " CalpicoState --> FileService[File Upload Service]", " APIServer --> CalpicoUtils[calpico_utils (post -> messages)]", " CalpicoUtils --> APIServer", " ClientApp --> UIComponents[UI Components (Composer, Thread, Sidebar)]", " WebSocket --> CalpicoState", ] .join("\n"); let fixed = fix_mermaid_blocks(&raw); assert!(fixed.contains(r#"ClientApp["Client App - React"]"#)); assert!(fixed.contains(r#"CalpicoState["Calpico State (signals + React Query)"]"#)); assert!(fixed.contains(r#"APIServer["API Server (/api/calpico)"]"#)); assert!(fixed.contains(r#"UIComponents["UI Components (Composer, Thread, Sidebar)"]"#)); assert!(fixed.contains(r#"CalpicoUtils["calpico_utils (post -> messages)"]"#)); assert!(fixed.contains("post -> messages")); assert!(!fixed.contains("post --> messages")); } #[test] fn sequence_message_quotes_preserved() { let raw = r#"```mermaid sequenceDiagram API->>FileMgr: sanitize & upload files ("if any") ```"#; let fixed = fix_mermaid_blocks(raw); assert!(fixed.contains(r#"API->>FileMgr: sanitize & upload files ("if any")"#)); } #[test] fn sequence_receiver_underscore_repaired_to_colon() { let raw = r#"```mermaid sequenceDiagram ClientApp->>APIServer_ POST /api/calpico/rooms/{id}/messages APIServer->>MessageWriter_ validate membership, persist message MessageWriter-->>APIServer_ message record ```"#; let fixed = fix_mermaid_blocks(raw); assert!(fixed.contains("ClientApp->>APIServer: POST /api/calpico/rooms/{id}/messages")); assert!(fixed.contains("APIServer->>MessageWriter: validate membership, persist message")); assert!(fixed.contains("MessageWriter-->>APIServer: message record")); } #[test] fn header_title_on_same_line_is_split() { let raw = "```mermaid\nflowchart TD title Component request flow - end-to-end platform\n Client[\"Tenant client / automation workflow\"] --> ChatService[\"packages/chat-service\"]\n```"; let fixed = fix_mermaid_blocks(raw); // Ensure we no longer have a single line with both the direction and title. assert!(!fixed.contains("flowchart TD title")); // We expect a header line followed by a separate title directive line. assert!( fixed.contains("flowchart TD\n title Component request flow - end-to-end platform") ); } #[test] fn round_trip_no_mermaid_returns_input() { let raw = "This markdown has no mermaid.\n\n```rust\nfn main() {}\n```\n"; let fixed = fix_mermaid_blocks(raw); assert_eq!(raw, fixed); } }