tftio-asana-cli 2.4.0

An interface to the Asana API
Documentation
//! Asana rich text preparation and validation.
//!
//! The Asana API requires HTML rich text fields (`html_notes`, `html_text`) to
//! be valid XML wrapped in `<body>` tags. This module provides a preparation
//! pipeline that:
//!
//! 1. Auto-wraps content in `<body>` tags when missing
//! 2. Escapes bare ampersands that are not part of XML entity references
//! 3. Validates the result as well-formed XML
//!
//! # Errors
//!
//! Returns [`RichTextError`] when the content cannot be made into valid XML
//! (e.g., mismatched tags, unsupported nesting).

use regex::Regex;
use std::sync::LazyLock;
use thiserror::Error;

/// Errors from rich text preparation.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum RichTextError {
    /// Content is not valid XML after preparation.
    #[error("invalid rich text: {0}")]
    InvalidXml(String),
}

/// Regex matching bare ampersands not part of XML entity references.
///
/// Matches `&` NOT followed by `amp;`, `lt;`, `gt;`, `quot;`, `apos;`,
/// `#` + digits + `;`, or `#x` + hex digits + `;`.
static XML_ENTITY: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"^(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);").expect("valid regex")
});

/// Prepare an HTML rich text string for the Asana API.
///
/// Applies three stages:
/// 1. Wraps in `<body>` if not already wrapped
/// 2. Escapes bare `&` characters (those not part of entity references)
/// 3. Validates the result as well-formed XML
///
/// # Errors
///
/// Returns [`RichTextError::InvalidXml`] if the content cannot be parsed as
/// valid XML after preparation (e.g., mismatched or unclosed tags).
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)
}

/// Wrap content in `<body>` tags if not already wrapped.
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>")
}

/// Escape bare ampersands in the input.
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("&amp;");
        }
    }

    escaped
}

/// Validate that the input is well-formed XML.
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 &amp; Jerry");
    }

    #[test]
    fn escape_preserves_existing_entities() {
        assert_eq!(escape_bare_ampersands("Tom &amp; Jerry"), "Tom &amp; Jerry");
    }

    #[test]
    fn escape_preserves_numeric_entities() {
        assert_eq!(escape_bare_ampersands("&#169; 2026"), "&#169; 2026");
    }

    #[test]
    fn escape_preserves_hex_entities() {
        assert_eq!(escape_bare_ampersands("&#xA9; 2026"), "&#xA9; 2026");
    }

    #[test]
    fn escape_handles_multiple_bare_ampersands() {
        assert_eq!(escape_bare_ampersands("A & B & C"), "A &amp; B &amp; 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 &amp; Jerry</body>");
    }

    #[test]
    fn prepare_wraps_escapes_and_validates() {
        let result = prepare_rich_text("A & B").unwrap();
        assert_eq!(result, "<body>A &amp; B</body>");
    }

    #[test]
    fn prepare_passes_valid_through() {
        let input = "<body><strong>bold</strong> &amp; 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);
    }
}