//! Markdown to Slack mrkdwn conversion. /// Convert Markdown text to Slack mrkdwn format. /// /// Slack uses its own "mrkdwn" syntax which differs from standard Markdown. /// This function converts common Markdown constructs so messages render /// nicely in Slack instead of showing raw Markdown syntax. pub fn markdown_to_slack(text: &str) -> String { use crate::chat::util::normalize_line_breaks; use regex::Regex; use std::sync::LazyLock; let normalized = normalize_line_breaks(text); let text = normalized.as_str(); // Regexes are compiled once and reused across calls. static RE_FENCED_BLOCK: LazyLock = LazyLock::new(|| Regex::new(r"(?ms)^```.*?\n(.*?)^```").unwrap()); static RE_HEADER: LazyLock = LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap()); static RE_BOLD_ITALIC: LazyLock = LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap()); static RE_BOLD: LazyLock = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap()); static RE_STRIKETHROUGH: LazyLock = LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap()); static RE_LINK: LazyLock = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap()); // 1. Protect fenced code blocks by replacing them with placeholders. let mut code_blocks: Vec = Vec::new(); let protected = RE_FENCED_BLOCK.replace_all(text, |caps: ®ex::Captures| { let idx = code_blocks.len(); code_blocks.push(caps[0].to_string()); format!("\x00CODEBLOCK{idx}\x00") }); let mut out = protected.into_owned(); // 2. Headers → bold text. out = RE_HEADER.replace_all(&out, "*$1*").into_owned(); // 3. Bold+italic (***text***) → bold italic (*_text_*). out = RE_BOLD_ITALIC.replace_all(&out, "*_${1}_*").into_owned(); // 4. Bold (**text**) → Slack bold (*text*). out = RE_BOLD.replace_all(&out, "*$1*").into_owned(); // 5. Strikethrough (~~text~~) → Slack strikethrough (~text~). out = RE_STRIKETHROUGH.replace_all(&out, "~$1~").into_owned(); // 6. Links [text](url) → Slack mrkdwn format . out = RE_LINK.replace_all(&out, "<$2|$1>").into_owned(); // 7. Restore code blocks. for (idx, block) in code_blocks.iter().enumerate() { out = out.replace(&format!("\x00CODEBLOCK{idx}\x00"), block); } out } // ── Tests ─────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn slack_headers_become_bold() { assert_eq!(markdown_to_slack("# Title"), "*Title*"); assert_eq!(markdown_to_slack("## Subtitle"), "*Subtitle*"); assert_eq!(markdown_to_slack("### Section"), "*Section*"); assert_eq!(markdown_to_slack("###### Deep"), "*Deep*"); } #[test] fn slack_bold_converted() { assert_eq!(markdown_to_slack("**bold text**"), "*bold text*"); } #[test] fn slack_bold_italic_converted() { assert_eq!(markdown_to_slack("***emphasis***"), "*_emphasis_*"); } #[test] fn slack_strikethrough_converted() { assert_eq!(markdown_to_slack("~~removed~~"), "~removed~"); } #[test] fn slack_links_converted_to_mrkdwn() { assert_eq!( markdown_to_slack("[click here](https://example.com)"), "" ); } #[test] fn slack_inline_code_preserved() { assert_eq!(markdown_to_slack("use `foo()` here"), "use `foo()` here"); } #[test] fn slack_fenced_code_block_preserved() { let input = "```rust\nlet x = 1;\n```"; let output = markdown_to_slack(input); assert!( output.contains("let x = 1;"), "code block content must be preserved" ); assert!( output.contains("```"), "fenced code delimiters must be preserved" ); } #[test] fn slack_code_block_content_not_transformed() { let input = "```\n**not bold** # not header\n```"; let output = markdown_to_slack(input); assert!( output.contains("**not bold**"), "markdown inside code blocks must not be transformed" ); } #[test] fn slack_plain_text_unchanged() { let plain = "Hello, this is a plain message with no formatting."; assert_eq!(markdown_to_slack(plain), plain); } #[test] fn slack_empty_string_unchanged() { assert_eq!(markdown_to_slack(""), ""); } }