use regex::Regex;
use std::sync::LazyLock;
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum RichTextError {
#[error("invalid rich text: {0}")]
InvalidXml(String),
}
static XML_ENTITY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);").expect("valid regex")
});
pub fn prepare_rich_text(input: &str) -> Result<String, RichTextError> {
let wrapped = ensure_body_wrap(input);
let escaped = escape_bare_ampersands(&wrapped);
validate_xml(&escaped)?;
Ok(escaped)
}
fn ensure_body_wrap(input: &str) -> String {
let trimmed = input.trim();
if trimmed.starts_with("<body>") && trimmed.ends_with("</body>") {
return trimmed.to_string();
}
format!("<body>{trimmed}</body>")
}
fn escape_bare_ampersands(input: &str) -> String {
let mut escaped = String::with_capacity(input.len());
let mut iter = input.char_indices();
while let Some((index, ch)) = iter.next() {
if ch != '&' {
escaped.push(ch);
continue;
}
let tail_start = index + ch.len_utf8();
let tail = &input[tail_start..];
if let Some(entity) = XML_ENTITY.find(tail).filter(|matched| matched.start() == 0) {
escaped.push('&');
escaped.push_str(entity.as_str());
for _ in 0..entity.as_str().chars().count() {
let _ = iter.next();
}
} else {
escaped.push_str("&");
}
}
escaped
}
fn validate_xml(input: &str) -> Result<(), RichTextError> {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
let mut reader = Reader::from_str(input);
loop {
match reader.read_event() {
Ok(Event::Eof) => return Ok(()),
Ok(_) => {}
Err(error) => return Err(RichTextError::InvalidXml(error.to_string())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn body_wrap_adds_tags_when_missing() {
assert_eq!(
ensure_body_wrap("<em>hello</em>"),
"<body><em>hello</em></body>"
);
}
#[test]
fn body_wrap_preserves_existing_tags() {
assert_eq!(
ensure_body_wrap("<body><em>hello</em></body>"),
"<body><em>hello</em></body>"
);
}
#[test]
fn body_wrap_handles_plain_text() {
assert_eq!(ensure_body_wrap("hello world"), "<body>hello world</body>");
}
#[test]
fn body_wrap_handles_whitespace_around_body() {
assert_eq!(ensure_body_wrap(" <body>hi</body> "), "<body>hi</body>");
}
#[test]
fn escape_bare_ampersand() {
assert_eq!(escape_bare_ampersands("Tom & Jerry"), "Tom & Jerry");
}
#[test]
fn escape_preserves_existing_entities() {
assert_eq!(escape_bare_ampersands("Tom & Jerry"), "Tom & Jerry");
}
#[test]
fn escape_preserves_numeric_entities() {
assert_eq!(escape_bare_ampersands("© 2026"), "© 2026");
}
#[test]
fn escape_preserves_hex_entities() {
assert_eq!(escape_bare_ampersands("© 2026"), "© 2026");
}
#[test]
fn escape_handles_multiple_bare_ampersands() {
assert_eq!(escape_bare_ampersands("A & B & C"), "A & B & C");
}
#[test]
fn escape_idempotent() {
let once = escape_bare_ampersands("Tom & Jerry");
let twice = escape_bare_ampersands(&once);
assert_eq!(once, twice);
}
#[test]
fn validate_accepts_valid_xml() {
assert!(validate_xml("<body><em>hello</em></body>").is_ok());
}
#[test]
fn validate_rejects_mismatched_tags() {
let err = validate_xml("<body><em>hello</strong></body>").unwrap_err();
assert!(matches!(err, RichTextError::InvalidXml(_)));
}
#[test]
fn validate_rejects_unclosed_tags() {
let err = validate_xml("<body><em>hello</body>").unwrap_err();
assert!(matches!(err, RichTextError::InvalidXml(_)));
}
#[test]
fn validate_accepts_self_closing_tags() {
assert!(validate_xml("<body>line<hr/>end</body>").is_ok());
}
#[test]
fn prepare_wraps_and_validates() {
let result = prepare_rich_text("<em>hello</em>").unwrap();
assert_eq!(result, "<body><em>hello</em></body>");
}
#[test]
fn prepare_escapes_and_validates() {
let result = prepare_rich_text("<body>Tom & Jerry</body>").unwrap();
assert_eq!(result, "<body>Tom & Jerry</body>");
}
#[test]
fn prepare_wraps_escapes_and_validates() {
let result = prepare_rich_text("A & B").unwrap();
assert_eq!(result, "<body>A & B</body>");
}
#[test]
fn prepare_passes_valid_through() {
let input = "<body><strong>bold</strong> & done</body>";
assert_eq!(prepare_rich_text(input).unwrap(), input);
}
#[test]
fn prepare_rejects_unfixable_content() {
let result = prepare_rich_text("<body><em>bad</strong></body>");
assert!(result.is_err());
}
#[test]
fn prepare_handles_empty_body() {
let result = prepare_rich_text("<body></body>").unwrap();
assert_eq!(result, "<body></body>");
}
#[test]
fn prepare_handles_nested_lists() {
let input = "<body><ul><li>one</li><li>two</li></ul></body>";
assert_eq!(prepare_rich_text(input).unwrap(), input);
}
#[test]
fn prepare_handles_link_attributes() {
let input = r#"<body><a data-asana-gid="123">task</a></body>"#;
assert_eq!(prepare_rich_text(input).unwrap(), input);
}
}