use regex::Regex;
use std::sync::LazyLock;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RichTextContext {
TaskNotes,
StoryText,
ProjectBrief,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum RichTextError {
#[error("invalid rich text XML: {0}")]
InvalidXml(String),
#[error("root element must be <body>, found <{0}>")]
RootMustBeBody(String),
#[error("unsupported tag <{tag}> for {context:?}")]
UnsupportedTag {
tag: String,
context: RichTextContext,
},
#[error("attribute `{attr}` is not allowed on <{tag}>")]
DisallowedAttribute {
tag: String,
attr: String,
},
#[error("invalid data-asana-type value: {0}")]
InvalidDataAsanaType(String),
#[error("invalid nesting: <{child}> cannot appear inside <{parent}>")]
InvalidNesting {
parent: String,
child: String,
},
}
pub fn prepare_rich_text(input: &str, context: RichTextContext) -> Result<String, RichTextError> {
let wrapped = ensure_body_wrap(input);
let escaped = escape_bare_ampersands(&wrapped);
validate(&escaped, context)?;
Ok(escaped)
}
static XML_ENTITY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);")
.expect("the regex literal is valid at compile time")
});
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
}
const UNIVERSAL_TAGS: &[&str] = &[
"body",
"strong",
"em",
"u",
"s",
"code",
"a",
"ol",
"ul",
"li",
"blockquote",
"pre",
];
const TASK_EXTRA_TAGS: &[&str] = &["h1", "h2", "hr", "img"];
const PROJECT_BRIEF_EXTRA_TAGS: &[&str] = &["table", "tr", "td", "object"];
const NO_LIST_INSIDE: &[&str] = &["h1", "h2", "blockquote", "pre"];
const FORBIDDEN_INSIDE_LI: &[&str] = &["h1", "h2", "blockquote", "pre"];
const A_ATTRS: &[&str] = &[
"href",
"data-asana-gid",
"data-asana-type",
"data-asana-project",
"data-asana-tag",
"data-asana-dynamic",
];
const IMG_ATTRS: &[&str] = &[
"data-asana-gid",
"src",
"data-src-width",
"data-src-height",
"data-thumbnail-url",
"data-thumbnail-width",
"data-thumbnail-height",
"style",
];
const OBJECT_ATTRS: &[&str] = &["type", "data-asana-gid", "data", "data-asana-type"];
const DATA_ASANA_TYPE_VALUES: &[&str] = &[
"user",
"task",
"project",
"tag",
"conversation",
"project_status",
"team",
"search",
"attachment",
];
fn tag_allowed(tag: &str, context: RichTextContext) -> bool {
if UNIVERSAL_TAGS.contains(&tag) {
return true;
}
match context {
RichTextContext::StoryText => false,
RichTextContext::TaskNotes => TASK_EXTRA_TAGS.contains(&tag),
RichTextContext::ProjectBrief => {
TASK_EXTRA_TAGS.contains(&tag) || PROJECT_BRIEF_EXTRA_TAGS.contains(&tag)
}
}
}
fn allowed_attrs(tag: &str) -> Option<&'static [&'static str]> {
match tag {
"a" => Some(A_ATTRS),
"img" => Some(IMG_ATTRS),
"object" => Some(OBJECT_ATTRS),
_ => None,
}
}
fn validate(input: &str, context: RichTextContext) -> Result<(), RichTextError> {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
let mut reader = Reader::from_str(input);
let mut stack: Vec<String> = Vec::new();
let mut saw_root = false;
loop {
match reader.read_event() {
Ok(Event::Eof) => return Ok(()),
Ok(Event::Start(e)) => {
let tag = std::str::from_utf8(e.name().as_ref())
.map_err(|err| RichTextError::InvalidXml(err.to_string()))?
.to_string();
check_element(&tag, &e, &stack, context, saw_root)?;
saw_root = true;
stack.push(tag);
}
Ok(Event::Empty(e)) => {
let tag = std::str::from_utf8(e.name().as_ref())
.map_err(|err| RichTextError::InvalidXml(err.to_string()))?
.to_string();
check_element(&tag, &e, &stack, context, saw_root)?;
saw_root = true;
}
Ok(Event::End(_)) => {
stack.pop();
}
Ok(_) => {}
Err(error) => return Err(RichTextError::InvalidXml(error.to_string())),
}
}
}
fn check_element(
tag: &str,
bytes: &quick_xml::events::BytesStart,
stack: &[String],
context: RichTextContext,
saw_root: bool,
) -> Result<(), RichTextError> {
if !saw_root && tag != "body" {
return Err(RichTextError::RootMustBeBody(tag.to_string()));
}
if !tag_allowed(tag, context) {
return Err(RichTextError::UnsupportedTag {
tag: tag.to_string(),
context,
});
}
check_nesting(tag, stack)?;
check_attributes(tag, bytes)?;
Ok(())
}
fn check_nesting(tag: &str, stack: &[String]) -> Result<(), RichTextError> {
for ancestor in stack.iter().rev() {
let a = ancestor.as_str();
if a == "li" && FORBIDDEN_INSIDE_LI.contains(&tag) {
return Err(RichTextError::InvalidNesting {
parent: a.to_string(),
child: tag.to_string(),
});
}
if NO_LIST_INSIDE.contains(&a) && (tag == "ol" || tag == "ul" || tag == "li") {
return Err(RichTextError::InvalidNesting {
parent: a.to_string(),
child: tag.to_string(),
});
}
}
Ok(())
}
fn check_attributes(tag: &str, bytes: &quick_xml::events::BytesStart) -> Result<(), RichTextError> {
let allowed = allowed_attrs(tag);
for attr in bytes.attributes() {
let attr = attr.map_err(|err| RichTextError::InvalidXml(err.to_string()))?;
let name = std::str::from_utf8(attr.key.as_ref())
.map_err(|err| RichTextError::InvalidXml(err.to_string()))?
.to_string();
let Some(allow_list) = allowed else {
return Err(RichTextError::DisallowedAttribute {
tag: tag.to_string(),
attr: name,
});
};
if !allow_list.contains(&name.as_str()) {
return Err(RichTextError::DisallowedAttribute {
tag: tag.to_string(),
attr: name,
});
}
if name == "data-asana-type" {
let value = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.map_err(|err| RichTextError::InvalidXml(err.to_string()))?;
if !DATA_ASANA_TYPE_VALUES.contains(&value.as_ref()) {
return Err(RichTextError::InvalidDataAsanaType(value.into_owned()));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn task(s: &str) -> Result<String, RichTextError> {
prepare_rich_text(s, RichTextContext::TaskNotes)
}
fn story(s: &str) -> Result<String, RichTextError> {
prepare_rich_text(s, RichTextContext::StoryText)
}
#[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 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_idempotent() {
let once = escape_bare_ampersands("Tom & Jerry");
let twice = escape_bare_ampersands(&once);
assert_eq!(once, twice);
}
#[test]
fn task_accepts_universal_tags() {
assert!(task("<body><strong>bold</strong> <em>i</em> <code>x</code></body>").is_ok());
}
#[test]
fn task_accepts_h1_and_hr() {
assert!(task("<body><h1>Title</h1><hr/></body>").is_ok());
}
#[test]
fn story_rejects_h1() {
let err = story("<body><h1>Nope</h1></body>").unwrap_err();
assert!(matches!(
err,
RichTextError::UnsupportedTag { ref tag, .. } if tag == "h1"
));
}
#[test]
fn story_accepts_universal_tags() {
assert!(story("<body><strong>x</strong> & <em>y</em></body>").is_ok());
}
#[test]
fn rejects_unknown_tag() {
let err = task("<body><div>hi</div></body>").unwrap_err();
assert!(matches!(
err,
RichTextError::UnsupportedTag { ref tag, .. } if tag == "div"
));
}
#[test]
fn rejects_p_tag() {
let err = task("<body><p>paragraph</p></body>").unwrap_err();
assert!(matches!(err, RichTextError::UnsupportedTag { .. }));
}
#[test]
fn rejects_attribute_on_strong() {
let err = task(r#"<body><strong class="x">hi</strong></body>"#).unwrap_err();
assert!(matches!(
err,
RichTextError::DisallowedAttribute { ref tag, ref attr }
if tag == "strong" && attr == "class"
));
}
#[test]
fn accepts_a_with_href_and_gid() {
assert!(task(
r#"<body><a href="https://x" data-asana-gid="123" data-asana-type="task">t</a></body>"#
)
.is_ok());
}
#[test]
fn rejects_unknown_a_attribute() {
let err = task(r#"<body><a target="_blank">x</a></body>"#).unwrap_err();
assert!(matches!(
err,
RichTextError::DisallowedAttribute { ref attr, .. } if attr == "target"
));
}
#[test]
fn rejects_invalid_data_asana_type() {
let err = task(r#"<body><a data-asana-type="bogus">x</a></body>"#).unwrap_err();
assert!(matches!(err, RichTextError::InvalidDataAsanaType(ref v) if v == "bogus"));
}
#[test]
fn rejects_h1_inside_li() {
let err = task("<body><ul><li><h1>nope</h1></li></ul></body>").unwrap_err();
assert!(matches!(
err,
RichTextError::InvalidNesting { ref parent, ref child }
if parent == "li" && child == "h1"
));
}
#[test]
fn rejects_blockquote_inside_li() {
let err = task("<body><ul><li><blockquote>x</blockquote></li></ul></body>").unwrap_err();
assert!(matches!(err, RichTextError::InvalidNesting { .. }));
}
#[test]
fn rejects_list_inside_blockquote() {
let err = task("<body><blockquote><ul><li>x</li></ul></blockquote></body>").unwrap_err();
assert!(matches!(
err,
RichTextError::InvalidNesting { ref parent, ref child }
if parent == "blockquote" && child == "ul"
));
}
#[test]
fn rejects_list_inside_pre() {
let err = task("<body><pre><ol><li>x</li></ol></pre></body>").unwrap_err();
assert!(matches!(err, RichTextError::InvalidNesting { .. }));
}
#[test]
fn rejects_uppercase_tag() {
let err = task("<BODY>x</BODY>").unwrap_err();
assert!(
matches!(
err,
RichTextError::UnsupportedTag { ref tag, .. } if tag == "BODY"
|| tag.eq_ignore_ascii_case("body")
) || matches!(err, RichTextError::RootMustBeBody(_))
);
}
#[test]
fn accepts_self_closing_hr_in_task() {
assert!(task("<body>line<hr/>end</body>").is_ok());
}
#[test]
fn rejects_mismatched_tags() {
let err = task("<body><em>x</strong></body>").unwrap_err();
assert!(matches!(err, RichTextError::InvalidXml(_)));
}
#[test]
fn prepare_wraps_and_validates() {
let result = task("<em>hello</em>").unwrap();
assert_eq!(result, "<body><em>hello</em></body>");
}
#[test]
fn prepare_escapes_and_validates() {
let result = task("<body>Tom & Jerry</body>").unwrap();
assert_eq!(result, "<body>Tom & Jerry</body>");
}
#[test]
fn prepare_passes_valid_through() {
let input = "<body><strong>bold</strong> & done</body>";
assert_eq!(task(input).unwrap(), input);
}
#[test]
fn prepare_handles_nested_lists() {
let input = "<body><ul><li>one</li><li>two</li></ul></body>";
assert_eq!(task(input).unwrap(), input);
}
#[test]
fn prepare_handles_link_attributes() {
let input = r#"<body><a data-asana-gid="123" data-asana-type="task">t</a></body>"#;
assert_eq!(task(input).unwrap(), input);
}
#[test]
fn project_brief_allows_table() {
let input = "<body><table><tr><td>cell</td></tr></table></body>";
assert!(prepare_rich_text(input, RichTextContext::ProjectBrief).is_ok());
}
#[test]
fn task_rejects_table() {
let err = task("<body><table><tr><td>c</td></tr></table></body>").unwrap_err();
assert!(matches!(err, RichTextError::UnsupportedTag { .. }));
}
}