use regex::Regex;
use std::collections::HashMap;
const STRIP_MACROS: &[&str] = &[
"jira",
"jirachart",
"roadmap",
"chart",
"drawio",
"gliffy",
"lucidchart",
"confluence-embedded-file",
"widget",
"iframe",
"html",
"gallery",
"calendar",
"livesearch",
"pagetree",
"children",
"recently-updated",
"content-by-label",
"blog-posts",
"change-history",
"contributors",
"profile-picture",
"user-profile",
"space-details",
"page-info",
"attachments",
"include",
"excerpt-include",
"multiexcerpt-include",
];
fn process_code_macro(macro_html: &str) -> String {
let lang_re = Regex::new(r#"(?i)ac:parameter\s+ac:name="language"[^>]*>([^<]*)"#).unwrap();
let language = lang_re
.captures(macro_html)
.map_or("", |c| c.get(1).map_or("", |m| m.as_str()))
.trim()
.to_string();
let cdata_re = Regex::new(r"<!\[CDATA\[([\s\S]*?)\]\]>").unwrap();
let body_re =
Regex::new(r"(?i)<ac:plain-text-body[^>]*>([\s\S]*?)</ac:plain-text-body>").unwrap();
let code = if let Some(cap) = cdata_re.captures(macro_html) {
cap[1].to_string()
} else if let Some(cap) = body_re.captures(macro_html) {
let inner = &cap[1];
let cdata_inner = Regex::new(r"<!\[CDATA\[([\s\S]*?)\]\]>").unwrap();
cdata_inner
.captures(inner)
.map_or_else(|| inner.to_string(), |c| c[1].to_string())
} else {
String::new()
};
if !code.is_empty() {
format!("\n```{language}\n{}\n```\n", code.trim())
} else {
String::new()
}
}
fn process_panel_macro(macro_html: &str, macro_name: &str) -> String {
let title_re = Regex::new(r#"(?i)ac:parameter\s+ac:name="title"[^>]*>([^<]*)"#).unwrap();
let title = title_re
.captures(macro_html)
.map_or(String::new(), |c| c[1].trim().to_string());
let body_re =
Regex::new(r"(?i)<ac:rich-text-body[^>]*>([\s\S]*?)</ac:rich-text-body>").unwrap();
let content = body_re
.captures(macro_html)
.map_or(String::new(), |c| c[1].trim().to_string());
let mut emojis = HashMap::new();
emojis.insert("info", "ℹ️");
emojis.insert("note", "📝");
emojis.insert("warning", "⚠️");
emojis.insert("tip", "💡");
emojis.insert("panel", "📋");
let emoji = emojis.get(macro_name).unwrap_or(&"📌");
let upper = macro_name.to_uppercase();
let header = if title.is_empty() {
format!("**{emoji} {upper}**")
} else {
format!("**{emoji} {upper}: {title}**")
};
if content.is_empty() {
format!("\n> {header}\n")
} else {
let quoted = content.replace('\n', "\n> ");
format!("\n> {header}\n> {quoted}\n")
}
}
fn process_expand_macro(macro_html: &str) -> String {
let title_re = Regex::new(r#"(?i)ac:parameter\s+ac:name="title"[^>]*>([^<]*)"#).unwrap();
let title = title_re
.captures(macro_html)
.map_or("Expand".to_string(), |c| c[1].trim().to_string());
let body_re =
Regex::new(r"(?i)<ac:rich-text-body[^>]*>([\s\S]*?)</ac:rich-text-body>").unwrap();
let content = body_re
.captures(macro_html)
.map_or(String::new(), |c| c[1].trim().to_string());
format!("\n<details>\n<summary>{title}</summary>\n\n{content}\n</details>\n")
}
fn process_status_macro(macro_html: &str) -> String {
let title_re = Regex::new(r#"(?i)ac:parameter\s+ac:name="title"[^>]*>([^<]*)"#).unwrap();
let title = title_re
.captures(macro_html)
.map_or(String::new(), |c| c[1].trim().to_string());
let color_re = Regex::new(r#"(?i)ac:parameter\s+ac:name="colour"[^>]*>([^<]*)"#).unwrap();
let color = color_re
.captures(macro_html)
.map_or(String::new(), |c| c[1].trim().to_lowercase());
let emoji = match color.as_str() {
"green" => "🟢",
"yellow" => "🟡",
"red" => "🔴",
"blue" => "🔵",
"grey" | "gray" => "⚪",
_ => "⚪",
};
if title.is_empty() {
String::new()
} else {
format!("{emoji} {title}")
}
}
fn process_quote_macro(macro_html: &str) -> String {
let body_re =
Regex::new(r"(?i)<ac:rich-text-body[^>]*>([\s\S]*?)</ac:rich-text-body>").unwrap();
let content = body_re
.captures(macro_html)
.map_or(String::new(), |c| c[1].trim().to_string());
if content.is_empty() {
String::new()
} else {
let quoted = content.replace('\n', "\n> ");
format!("\n> {quoted}\n")
}
}
pub fn process_confluence_macros(html: &str) -> String {
if html.is_empty() {
return String::new();
}
let mut result = html.to_string();
let macro_re = Regex::new(
r#"(?is)<ac:structured-macro[^>]*ac:name="([^"]*)"[^>]*>([\s\S]*?)</ac:structured-macro>"#,
)
.unwrap();
result = macro_re
.replace_all(&result, |caps: ®ex::Captures| {
let name = caps[1].to_lowercase();
let full_match = caps.get(0).unwrap().as_str();
match name.as_str() {
"code" => process_code_macro(full_match),
"info" | "note" | "warning" | "tip" | "panel" => {
process_panel_macro(full_match, &name)
}
"expand" => process_expand_macro(full_match),
"status" => process_status_macro(full_match),
"toc" => "\n[Table of Contents]\n".to_string(),
"quote" => process_quote_macro(full_match),
"excerpt" => {
let body_re = Regex::new(
r"(?i)<ac:rich-text-body[^>]*>([\s\S]*?)</ac:rich-text-body>",
)
.unwrap();
body_re
.captures(full_match)
.map_or(String::new(), |c| c[1].to_string())
}
"anchor" => {
let param_re = Regex::new(r"ac:parameter[^>]*>([^<]*)").unwrap();
param_re.captures(full_match).map_or(String::new(), |c| {
format!(r#"<a id="{}"></a>"#, &c[1])
})
}
_ if STRIP_MACROS.contains(&name.as_str()) => {
format!("\n[{name} macro removed]\n")
}
_ => {
let tag_re = Regex::new(r"<[^>]*>").unwrap();
let text = tag_re.replace_all(full_match, "").trim().to_string();
if text.is_empty() {
format!("\n[{name} macro]\n")
} else {
let truncated = if text.len() > 100 {
format!("{}...", &text[..100])
} else {
text
};
format!("\n[{name}: {truncated}]\n")
}
}
}
})
.to_string();
let inline_re =
Regex::new(r"(?is)<ac:inline-comment-marker[^>]*>[\s\S]*?</ac:inline-comment-marker>")
.unwrap();
result = inline_re.replace_all(&result, "").to_string();
let emoticon_re = Regex::new(r#"(?i)<ac:emoticon[^>]*ac:name="([^"]*)"[^>]*/?\s*>"#).unwrap();
result = emoticon_re
.replace_all(&result, |caps: ®ex::Captures| {
match caps[1].to_lowercase().as_str() {
"smile" => "😊",
"sad" => "😢",
"tongue" => "😛",
"biggrin" => "😁",
"wink" => "😉",
"thumbs-up" => "👍",
"thumbs-down" => "👎",
"information" => "ℹ️",
"tick" => "✅",
"cross" => "❌",
"warning" => "⚠️",
"plus" => "➕",
"minus" => "➖",
"question" => "❓",
"light-on" | "light-off" => "💡",
"yellow-star" | "red-star" | "green-star" | "blue-star" => "⭐",
_ => "",
}
.to_string()
})
.to_string();
let tl_re = Regex::new(r"(?is)<ac:task-list[^>]*>([\s\S]*?)</ac:task-list>").unwrap();
result = tl_re.replace_all(&result, "$1").to_string();
let task_re = Regex::new(r"(?is)<ac:task[^>]*>([\s\S]*?)</ac:task>").unwrap();
result = task_re.replace_all(&result, "- [ ] $1").to_string();
let ts_done_re = Regex::new(r"(?i)<ac:task-status[^>]*>complete</ac:task-status>").unwrap();
result = ts_done_re.replace_all(&result, "[x]").to_string();
let ts_re = Regex::new(r"(?i)<ac:task-status[^>]*>[^<]*</ac:task-status>").unwrap();
result = ts_re.replace_all(&result, "[ ]").to_string();
let tb_re = Regex::new(r"(?is)<ac:task-body[^>]*>([\s\S]*?)</ac:task-body>").unwrap();
result = tb_re.replace_all(&result, "$1").to_string();
let tid_re = Regex::new(r"(?i)<ac:task-id[^>]*>[^<]*</ac:task-id>").unwrap();
result = tid_re.replace_all(&result, "").to_string();
let ph_re = Regex::new(r"(?is)<ac:placeholder[^>]*>[\s\S]*?</ac:placeholder>").unwrap();
result = ph_re.replace_all(&result, "").to_string();
let img_re = Regex::new(r"(?is)<ac:image[^>]*>[\s\S]*?</ac:image>").unwrap();
result = img_re
.replace_all(&result, |caps: ®ex::Captures| {
let full = caps.get(0).unwrap().as_str();
let fn_re = Regex::new(r#"ri:filename="([^"]*)""#).unwrap();
fn_re.captures(full).map_or("[Image]".to_string(), |c| {
format!("[Image: {}]", &c[1])
})
})
.to_string();
let link_re = Regex::new(r"(?is)<ac:link[^>]*>[\s\S]*?</ac:link>").unwrap();
result = link_re
.replace_all(&result, |caps: ®ex::Captures| {
let full = caps.get(0).unwrap().as_str();
let title_re = Regex::new(r#"ri:content-title="([^"]*)""#).unwrap();
let lb_re =
Regex::new(r"(?is)<ac:link-body[^>]*>([\s\S]*?)</ac:link-body>").unwrap();
let plb_re = Regex::new(
r"(?is)<ac:plain-text-link-body[^>]*>([\s\S]*?)</ac:plain-text-link-body>",
)
.unwrap();
let page_title = title_re.captures(full).map(|c| c[1].to_string());
let link_text = lb_re
.captures(full)
.map(|c| c[1].to_string())
.or_else(|| plb_re.captures(full).map(|c| c[1].to_string()))
.or_else(|| page_title.clone())
.unwrap_or_else(|| "link".to_string());
if let Some(pt) = page_title {
format!("[{link_text}](confluence:{pt})")
} else {
link_text
}
})
.to_string();
result
}
pub struct Heading {
pub level: u8,
pub text: String,
#[allow(dead_code)]
pub id: Option<String>,
}
pub fn extract_headings(html: &str) -> Vec<Heading> {
let mut headings = Vec::new();
let heading_re = Regex::new(r#"(?is)<h([1-6])([^>]*)>([\s\S]*?)</h[1-6]>"#).unwrap();
let id_re = Regex::new(r#"id="([^"]*)""#).unwrap();
let tag_strip = Regex::new(r"<[^>]*>").unwrap();
for cap in heading_re.captures_iter(html) {
let level: u8 = cap[1].parse().unwrap_or(1);
let attrs = &cap[2];
let raw_text = &cap[3];
let id = id_re.captures(attrs).map(|c| c[1].to_string());
let text = decode_entities(&tag_strip.replace_all(raw_text, "")).trim().to_string();
if !text.is_empty() {
headings.push(Heading { level, text, id });
}
}
headings
}
pub fn extract_section(html: &str, heading_text: &str, heading_level: Option<u8>) -> Option<String> {
let normalized = heading_text.to_lowercase();
let heading_re = Regex::new(r"(?is)<h([1-6])[^>]*>([\s\S]*?)</h[1-6]>").unwrap();
let tag_strip = Regex::new(r"<[^>]*>").unwrap();
let mut start_index: Option<usize> = None;
let mut start_level: u8 = 0;
for cap in heading_re.captures_iter(html) {
let level: u8 = cap[1].parse().unwrap_or(1);
let text = decode_entities(&tag_strip.replace_all(&cap[2], ""))
.trim()
.to_lowercase();
let m = cap.get(0).unwrap();
if start_index.is_none() {
if text.contains(&normalized) && heading_level.map_or(true, |l| l == level) {
start_index = Some(m.end());
start_level = level;
}
} else if level <= start_level {
let section = &html[start_index.unwrap()..m.start()];
return Some(section.trim().to_string());
}
}
start_index.map(|si| html[si..].trim().to_string())
}
fn decode_entities(s: &str) -> String {
s.replace(" ", " ")
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input() {
assert_eq!(process_confluence_macros(""), "");
}
#[test]
fn passthrough_plain_html() {
let html = "<p>Hello world</p>";
assert_eq!(process_confluence_macros(html), html);
}
#[test]
fn code_macro_with_language() {
let html = r#"<ac:structured-macro ac:name="code"><ac:parameter ac:name="language">rust</ac:parameter><ac:plain-text-body><![CDATA[fn main() {}]]></ac:plain-text-body></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("```rust"));
assert!(result.contains("fn main() {}"));
assert!(result.contains("```"));
}
#[test]
fn code_macro_without_language() {
let html = r#"<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[some code]]></ac:plain-text-body></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("```\n"));
assert!(result.contains("some code"));
}
#[test]
fn info_panel_macro() {
let html = r#"<ac:structured-macro ac:name="info"><ac:parameter ac:name="title">Note Title</ac:parameter><ac:rich-text-body>Body text</ac:rich-text-body></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("ℹ️"));
assert!(result.contains("INFO"));
assert!(result.contains("Note Title"));
assert!(result.contains("Body text"));
}
#[test]
fn warning_panel_macro() {
let html = r#"<ac:structured-macro ac:name="warning"><ac:rich-text-body>Danger!</ac:rich-text-body></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("⚠️"));
assert!(result.contains("WARNING"));
assert!(result.contains("Danger!"));
}
#[test]
fn tip_panel_macro() {
let html = r#"<ac:structured-macro ac:name="tip"><ac:rich-text-body>A helpful tip</ac:rich-text-body></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("💡"));
assert!(result.contains("TIP"));
}
#[test]
fn note_panel_macro() {
let html = r#"<ac:structured-macro ac:name="note"><ac:rich-text-body>A note</ac:rich-text-body></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("📝"));
assert!(result.contains("NOTE"));
}
#[test]
fn panel_macro_empty_body() {
let html = r#"<ac:structured-macro ac:name="info"><ac:parameter ac:name="title">Title</ac:parameter><ac:rich-text-body></ac:rich-text-body></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("INFO: Title"));
}
#[test]
fn expand_macro() {
let html = r#"<ac:structured-macro ac:name="expand"><ac:parameter ac:name="title">Click me</ac:parameter><ac:rich-text-body>Hidden content</ac:rich-text-body></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("<details>"));
assert!(result.contains("<summary>Click me</summary>"));
assert!(result.contains("Hidden content"));
assert!(result.contains("</details>"));
}
#[test]
fn status_macro_green() {
let html = r#"<ac:structured-macro ac:name="status"><ac:parameter ac:name="title">Done</ac:parameter><ac:parameter ac:name="colour">Green</ac:parameter></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("🟢"));
assert!(result.contains("Done"));
}
#[test]
fn status_macro_red() {
let html = r#"<ac:structured-macro ac:name="status"><ac:parameter ac:name="title">Blocked</ac:parameter><ac:parameter ac:name="colour">Red</ac:parameter></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("🔴"));
assert!(result.contains("Blocked"));
}
#[test]
fn status_macro_no_title() {
let html = r#"<ac:structured-macro ac:name="status"><ac:parameter ac:name="colour">Blue</ac:parameter></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(!result.contains("🔵"));
}
#[test]
fn toc_macro() {
let html = r#"<ac:structured-macro ac:name="toc"><ac:parameter ac:name="style">flat</ac:parameter></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("[Table of Contents]"));
}
#[test]
fn quote_macro() {
let html = r#"<ac:structured-macro ac:name="quote"><ac:rich-text-body>A wise saying</ac:rich-text-body></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("> A wise saying"));
}
#[test]
fn excerpt_macro() {
let html = r#"<ac:structured-macro ac:name="excerpt"><ac:rich-text-body>Excerpt text here</ac:rich-text-body></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("Excerpt text here"));
}
#[test]
fn anchor_macro() {
let html = r#"<ac:structured-macro ac:name="anchor"><ac:parameter ac:name="">my-anchor</ac:parameter></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains(r#"<a id="my-anchor"></a>"#));
}
#[test]
fn strip_macro_jira() {
let html = r#"<ac:structured-macro ac:name="jira"><ac:parameter ac:name="key">PROJ-123</ac:parameter></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("[jira macro removed]"));
}
#[test]
fn strip_macro_drawio() {
let html = r#"<ac:structured-macro ac:name="drawio"><ac:parameter ac:name="name">diagram</ac:parameter></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("[drawio macro removed]"));
}
#[test]
fn unknown_macro_with_text() {
let html = r#"<ac:structured-macro ac:name="custom-thing"><ac:rich-text-body>Some text</ac:rich-text-body></ac:structured-macro>"#;
let result = process_confluence_macros(html);
assert!(result.contains("[custom-thing:"));
}
#[test]
fn emoticon_smile() {
let html = r#"<ac:emoticon ac:name="smile" />"#;
let result = process_confluence_macros(html);
assert!(result.contains("😊"));
}
#[test]
fn emoticon_tick() {
let html = r#"<ac:emoticon ac:name="tick" />"#;
let result = process_confluence_macros(html);
assert!(result.contains("✅"));
}
#[test]
fn emoticon_warning() {
let html = r#"<ac:emoticon ac:name="warning" />"#;
let result = process_confluence_macros(html);
assert!(result.contains("⚠️"));
}
#[test]
fn task_list_complete() {
let html = r#"<ac:task-list><ac:task><ac:task-id>1</ac:task-id><ac:task-status>complete</ac:task-status><ac:task-body>Done item</ac:task-body></ac:task></ac:task-list>"#;
let result = process_confluence_macros(html);
assert!(result.contains("[x]"));
assert!(result.contains("Done item"));
}
#[test]
fn task_list_incomplete() {
let html = r#"<ac:task-list><ac:task><ac:task-id>2</ac:task-id><ac:task-status>incomplete</ac:task-status><ac:task-body>Todo item</ac:task-body></ac:task></ac:task-list>"#;
let result = process_confluence_macros(html);
assert!(result.contains("[ ]"));
assert!(result.contains("Todo item"));
}
#[test]
fn image_attachment() {
let html = r#"<ac:image><ri:attachment ri:filename="diagram.png" /></ac:image>"#;
let result = process_confluence_macros(html);
assert!(result.contains("[Image: diagram.png]"));
}
#[test]
fn confluence_link_with_title() {
let html = r#"<ac:link><ri:page ri:content-title="My Page" /><ac:link-body>Click here</ac:link-body></ac:link>"#;
let result = process_confluence_macros(html);
assert!(result.contains("[Click here](confluence:My Page)"));
}
#[test]
fn confluence_link_no_body() {
let html = r#"<ac:link><ri:page ri:content-title="Target Page" /></ac:link>"#;
let result = process_confluence_macros(html);
assert!(result.contains("[Target Page](confluence:Target Page)"));
}
#[test]
fn inline_comment_stripped() {
let html = r#"<ac:inline-comment-marker ac:ref="abc123">commented text</ac:inline-comment-marker>"#;
let result = process_confluence_macros(html);
assert!(!result.contains("ac:inline-comment-marker"));
assert!(!result.contains("commented text"));
}
#[test]
fn placeholder_stripped() {
let html = r#"<ac:placeholder>Type something here</ac:placeholder>"#;
let result = process_confluence_macros(html);
assert!(!result.contains("Type something here"));
}
#[test]
fn extract_headings_basic() {
let html = "<h1>Title</h1><p>Content</p><h2>Subtitle</h2>";
let headings = extract_headings(html);
assert_eq!(headings.len(), 2);
assert_eq!(headings[0].level, 1);
assert_eq!(headings[0].text, "Title");
assert_eq!(headings[1].level, 2);
assert_eq!(headings[1].text, "Subtitle");
}
#[test]
fn extract_headings_with_id() {
let html = r#"<h2 id="my-section">Section</h2>"#;
let headings = extract_headings(html);
assert_eq!(headings.len(), 1);
assert_eq!(headings[0].id, Some("my-section".to_string()));
}
#[test]
fn extract_headings_with_nested_tags() {
let html = "<h1><strong>Bold Title</strong></h1>";
let headings = extract_headings(html);
assert_eq!(headings.len(), 1);
assert_eq!(headings[0].text, "Bold Title");
}
#[test]
fn extract_headings_empty_heading_skipped() {
let html = "<h1> </h1><h2>Valid</h2>";
let headings = extract_headings(html);
assert_eq!(headings.len(), 1);
assert_eq!(headings[0].text, "Valid");
}
#[test]
fn extract_headings_with_entities() {
let html = "<h1>A & B</h1>";
let headings = extract_headings(html);
assert_eq!(headings[0].text, "A & B");
}
#[test]
fn extract_headings_no_headings() {
let html = "<p>Just a paragraph</p>";
let headings = extract_headings(html);
assert!(headings.is_empty());
}
#[test]
fn extract_section_basic() {
let html = "<h1>Intro</h1><p>Intro text</p><h1>Details</h1><p>Detail text</p>";
let section = extract_section(html, "Intro", None);
assert!(section.is_some());
let s = section.unwrap();
assert!(s.contains("Intro text"));
assert!(!s.contains("Detail text"));
}
#[test]
fn extract_section_last_section() {
let html = "<h1>First</h1><p>A</p><h1>Last</h1><p>B</p>";
let section = extract_section(html, "Last", None);
assert!(section.is_some());
assert!(section.unwrap().contains("B"));
}
#[test]
fn extract_section_with_level_filter() {
let html = "<h1>Intro</h1><p>L1</p><h2>Intro</h2><p>L2</p>";
let section = extract_section(html, "Intro", Some(2));
assert!(section.is_some());
let s = section.unwrap();
assert!(s.contains("L2"));
}
#[test]
fn extract_section_not_found() {
let html = "<h1>Existing</h1><p>Content</p>";
let section = extract_section(html, "Missing", None);
assert!(section.is_none());
}
#[test]
fn extract_section_partial_match() {
let html = "<h2>API Reference Guide</h2><p>Content here</p><h2>Other</h2>";
let section = extract_section(html, "api reference", None);
assert!(section.is_some());
assert!(section.unwrap().contains("Content here"));
}
#[test]
fn extract_section_nested_headings() {
let html = "<h1>Parent</h1><p>P text</p><h2>Child</h2><p>C text</p><h1>Sibling</h1><p>S text</p>";
let section = extract_section(html, "Parent", None);
assert!(section.is_some());
let s = section.unwrap();
assert!(s.contains("P text"));
assert!(s.contains("Child"));
assert!(s.contains("C text"));
assert!(!s.contains("S text"));
}
#[test]
fn decode_entities_all() {
assert_eq!(decode_entities(" &<>"'"), " &<>\"'");
}
#[test]
fn decode_entities_none() {
assert_eq!(decode_entities("plain text"), "plain text");
}
}