xdoc-rs 0.1.1

Declarative XML engine for Rust
Documentation
//! Test helpers for the XML engine.
//!
//! These helpers are intentionally generic and domain-free. They centralize
//! fixture paths, golden file comparisons, and parser/writer roundtrips so
//! modules do not need to duplicate ad-hoc test utilities.

use std::error::Error;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};

use crate::core::{Document, ErrorKind, XmlError, XmlResult};
use crate::parser::{parse_str, parse_str_with_config, ParserConfig};
use crate::writer::{to_string_compact, to_string_with_config, WriterConfig};

/// Repository-relative directory for XML fixtures.
pub const XML_FIXTURES_DIR: &str = "tests/fixtures/xml";

/// Repository-relative directory for golden files.
pub const GOLDEN_DIR: &str = "tests/golden";

/// Result of parsing, serializing, reparsing, and serializing again.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoundtripResult {
    original: Document,
    compact_xml: String,
    reparsed: Document,
    reserialized_xml: String,
}

impl RoundtripResult {
    pub fn original(&self) -> &Document {
        &self.original
    }

    pub fn compact_xml(&self) -> &str {
        &self.compact_xml
    }

    pub fn reparsed(&self) -> &Document {
        &self.reparsed
    }

    pub fn reserialized_xml(&self) -> &str {
        &self.reserialized_xml
    }
}

/// Compact, line-oriented diff for XML golden comparisons.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct XmlDiff {
    expected: String,
    actual: String,
    summary: String,
}

impl XmlDiff {
    pub fn new(expected: impl Into<String>, actual: impl Into<String>) -> Self {
        let expected = expected.into();
        let actual = actual.into();
        let summary = diff_summary(&expected, &actual);

        Self {
            expected,
            actual,
            summary,
        }
    }

    pub fn expected(&self) -> &str {
        &self.expected
    }

    pub fn actual(&self) -> &str {
        &self.actual
    }

    pub fn summary(&self) -> &str {
        &self.summary
    }
}

impl fmt::Display for XmlDiff {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            formatter,
            "XML output differs from expected golden file\n{}\nexpected:\n{}\nactual:\n{}",
            self.summary, self.expected, self.actual
        )
    }
}

impl Error for XmlDiff {}

/// Resolves a path relative to the repository root.
pub fn repo_path(root: impl AsRef<Path>, relative: impl AsRef<Path>) -> PathBuf {
    root.as_ref().join(relative)
}

/// Resolves an XML fixture path relative to the repository root.
pub fn xml_fixture_path(root: impl AsRef<Path>, name: impl AsRef<Path>) -> PathBuf {
    repo_path(root, XML_FIXTURES_DIR).join(name)
}

/// Resolves a golden file path relative to the repository root.
pub fn golden_path(root: impl AsRef<Path>, name: impl AsRef<Path>) -> PathBuf {
    repo_path(root, GOLDEN_DIR).join(name)
}

/// Reads a UTF-8 file and maps filesystem errors to `XmlError`.
pub fn read_utf8_file(path: impl AsRef<Path>) -> XmlResult<String> {
    let path = path.as_ref();
    fs::read_to_string(path).map_err(|error| {
        XmlError::new(
            ErrorKind::Io,
            format!("failed to read `{}`: {error}", path.display()),
        )
    })
}

/// Reads an XML fixture from `tests/fixtures/xml`.
pub fn read_xml_fixture(root: impl AsRef<Path>, name: impl AsRef<Path>) -> XmlResult<String> {
    read_utf8_file(xml_fixture_path(root, name))
}

/// Reads a golden file from `tests/golden`.
pub fn read_golden(root: impl AsRef<Path>, name: impl AsRef<Path>) -> XmlResult<String> {
    read_utf8_file(golden_path(root, name))
}

/// Compares XML strings while allowing a single final line ending in fixtures.
pub fn compare_xml(expected: &str, actual: &str) -> Result<(), XmlDiff> {
    let expected = without_final_line_ending(expected);
    let actual = without_final_line_ending(actual);

    if expected == actual {
        Ok(())
    } else {
        Err(XmlDiff::new(expected, actual))
    }
}

/// Panics with a compact diff when two XML strings differ.
pub fn assert_xml_eq(expected: &str, actual: &str) {
    if let Err(diff) = compare_xml(expected, actual) {
        panic!("{diff}");
    }
}

/// Reads a golden file and compares it with actual XML output.
pub fn assert_matches_golden(
    root: impl AsRef<Path>,
    golden_name: impl AsRef<Path>,
    actual: &str,
) -> XmlResult<()> {
    let expected = read_golden(root, golden_name)?;
    assert_xml_eq(&expected, actual);
    Ok(())
}

/// Parses XML, serializes it compactly, reparses it, and verifies stability.
pub fn assert_compact_roundtrip(xml: &str) -> XmlResult<RoundtripResult> {
    assert_compact_roundtrip_with_config(xml, &ParserConfig::default())
}

/// Parses XML with explicit config, serializes compactly, reparses it, and verifies stability.
pub fn assert_compact_roundtrip_with_config(
    xml: &str,
    config: &ParserConfig,
) -> XmlResult<RoundtripResult> {
    let original = parse_str_with_config(xml, config)?;
    let compact_xml = to_string_compact(&original)?;
    let reparsed = parse_str_with_config(&compact_xml, config)?;
    let reserialized_xml = to_string_compact(&reparsed)?;

    if compact_xml != reserialized_xml {
        return Err(XmlError::new(
            ErrorKind::InvalidOperation,
            XmlDiff::new(&compact_xml, &reserialized_xml).to_string(),
        ));
    }

    Ok(RoundtripResult {
        original,
        compact_xml,
        reparsed,
        reserialized_xml,
    })
}

/// Serializes a document using an explicit writer configuration and compares it to a golden file.
pub fn assert_document_matches_golden(
    root: impl AsRef<Path>,
    golden_name: impl AsRef<Path>,
    document: &Document,
    config: &WriterConfig,
) -> XmlResult<()> {
    let actual = to_string_with_config(document, config)?;
    assert_matches_golden(root, golden_name, &actual)
}

/// Parses a fixture from `tests/fixtures/xml`.
pub fn parse_xml_fixture(root: impl AsRef<Path>, name: impl AsRef<Path>) -> XmlResult<Document> {
    let xml = read_xml_fixture(root, name)?;
    parse_str(&xml)
}

fn without_final_line_ending(value: &str) -> &str {
    value
        .strip_suffix("\r\n")
        .or_else(|| value.strip_suffix('\n'))
        .unwrap_or(value)
}

fn diff_summary(expected: &str, actual: &str) -> String {
    let expected_lines: Vec<_> = expected.lines().collect();
    let actual_lines: Vec<_> = actual.lines().collect();
    let line_count = expected_lines.len().max(actual_lines.len());

    for index in 0..line_count {
        let expected_line = expected_lines.get(index).copied().unwrap_or("");
        let actual_line = actual_lines.get(index).copied().unwrap_or("");
        if expected_line != actual_line {
            let column = first_different_column(expected_line, actual_line);
            return format!(
                "first difference at line {}, column {}\nexpected line: {}\nactual line:   {}",
                index + 1,
                column,
                expected_line,
                actual_line
            );
        }
    }

    format!(
        "length differs: expected {} bytes, actual {} bytes",
        expected.len(),
        actual.len()
    )
}

fn first_different_column(expected: &str, actual: &str) -> usize {
    let mut expected_chars = expected.chars();
    let mut actual_chars = actual.chars();
    let mut column = 1;

    loop {
        match (expected_chars.next(), actual_chars.next()) {
            (Some(left), Some(right)) if left == right => column += 1,
            _ => return column,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn testing_compare_xml_allows_final_fixture_newline() {
        compare_xml("<Root/>\n", "<Root/>").expect("only final newline differs");
    }

    #[test]
    fn testing_compare_xml_reports_first_different_line() {
        let error = compare_xml("<Root>\n  <A/>\n</Root>", "<Root>\n  <B/>\n</Root>")
            .expect_err("XML must differ");

        assert!(error.summary().contains("line 2"));
        assert!(error.summary().contains("<A/>"));
        assert!(error.summary().contains("<B/>"));
    }

    #[test]
    fn testing_compact_roundtrip_stabilizes_parser_writer_output() -> XmlResult<()> {
        let result = assert_compact_roundtrip("<Root><Item>A</Item><Item>B</Item></Root>")?;

        assert_eq!(
            result.compact_xml(),
            "<Root><Item>A</Item><Item>B</Item></Root>"
        );
        assert_eq!(result.compact_xml(), result.reserialized_xml());
        assert_eq!(result.original(), result.reparsed());
        Ok(())
    }
}