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};
pub const XML_FIXTURES_DIR: &str = "tests/fixtures/xml";
pub const GOLDEN_DIR: &str = "tests/golden";
#[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
}
}
#[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 {}
pub fn repo_path(root: impl AsRef<Path>, relative: impl AsRef<Path>) -> PathBuf {
root.as_ref().join(relative)
}
pub fn xml_fixture_path(root: impl AsRef<Path>, name: impl AsRef<Path>) -> PathBuf {
repo_path(root, XML_FIXTURES_DIR).join(name)
}
pub fn golden_path(root: impl AsRef<Path>, name: impl AsRef<Path>) -> PathBuf {
repo_path(root, GOLDEN_DIR).join(name)
}
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()),
)
})
}
pub fn read_xml_fixture(root: impl AsRef<Path>, name: impl AsRef<Path>) -> XmlResult<String> {
read_utf8_file(xml_fixture_path(root, name))
}
pub fn read_golden(root: impl AsRef<Path>, name: impl AsRef<Path>) -> XmlResult<String> {
read_utf8_file(golden_path(root, name))
}
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))
}
}
pub fn assert_xml_eq(expected: &str, actual: &str) {
if let Err(diff) = compare_xml(expected, actual) {
panic!("{diff}");
}
}
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(())
}
pub fn assert_compact_roundtrip(xml: &str) -> XmlResult<RoundtripResult> {
assert_compact_roundtrip_with_config(xml, &ParserConfig::default())
}
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,
})
}
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)
}
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(())
}
}