1use std::error::Error;
8use std::fmt;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use crate::core::{Document, ErrorKind, XmlError, XmlResult};
13use crate::parser::{parse_str, parse_str_with_config, ParserConfig};
14use crate::writer::{to_string_compact, to_string_with_config, WriterConfig};
15
16pub const XML_FIXTURES_DIR: &str = "tests/fixtures/xml";
18
19pub const GOLDEN_DIR: &str = "tests/golden";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct RoundtripResult {
25 original: Document,
26 compact_xml: String,
27 reparsed: Document,
28 reserialized_xml: String,
29}
30
31impl RoundtripResult {
32 pub fn original(&self) -> &Document {
33 &self.original
34 }
35
36 pub fn compact_xml(&self) -> &str {
37 &self.compact_xml
38 }
39
40 pub fn reparsed(&self) -> &Document {
41 &self.reparsed
42 }
43
44 pub fn reserialized_xml(&self) -> &str {
45 &self.reserialized_xml
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct XmlDiff {
52 expected: String,
53 actual: String,
54 summary: String,
55}
56
57impl XmlDiff {
58 pub fn new(expected: impl Into<String>, actual: impl Into<String>) -> Self {
59 let expected = expected.into();
60 let actual = actual.into();
61 let summary = diff_summary(&expected, &actual);
62
63 Self {
64 expected,
65 actual,
66 summary,
67 }
68 }
69
70 pub fn expected(&self) -> &str {
71 &self.expected
72 }
73
74 pub fn actual(&self) -> &str {
75 &self.actual
76 }
77
78 pub fn summary(&self) -> &str {
79 &self.summary
80 }
81}
82
83impl fmt::Display for XmlDiff {
84 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85 write!(
86 formatter,
87 "XML output differs from expected golden file\n{}\nexpected:\n{}\nactual:\n{}",
88 self.summary, self.expected, self.actual
89 )
90 }
91}
92
93impl Error for XmlDiff {}
94
95pub fn repo_path(root: impl AsRef<Path>, relative: impl AsRef<Path>) -> PathBuf {
97 root.as_ref().join(relative)
98}
99
100pub fn xml_fixture_path(root: impl AsRef<Path>, name: impl AsRef<Path>) -> PathBuf {
102 repo_path(root, XML_FIXTURES_DIR).join(name)
103}
104
105pub fn golden_path(root: impl AsRef<Path>, name: impl AsRef<Path>) -> PathBuf {
107 repo_path(root, GOLDEN_DIR).join(name)
108}
109
110pub fn read_utf8_file(path: impl AsRef<Path>) -> XmlResult<String> {
112 let path = path.as_ref();
113 fs::read_to_string(path).map_err(|error| {
114 XmlError::new(
115 ErrorKind::Io,
116 format!("failed to read `{}`: {error}", path.display()),
117 )
118 })
119}
120
121pub fn read_xml_fixture(root: impl AsRef<Path>, name: impl AsRef<Path>) -> XmlResult<String> {
123 read_utf8_file(xml_fixture_path(root, name))
124}
125
126pub fn read_golden(root: impl AsRef<Path>, name: impl AsRef<Path>) -> XmlResult<String> {
128 read_utf8_file(golden_path(root, name))
129}
130
131pub fn compare_xml(expected: &str, actual: &str) -> Result<(), XmlDiff> {
133 let expected = without_final_line_ending(expected);
134 let actual = without_final_line_ending(actual);
135
136 if expected == actual {
137 Ok(())
138 } else {
139 Err(XmlDiff::new(expected, actual))
140 }
141}
142
143pub fn assert_xml_eq(expected: &str, actual: &str) {
145 if let Err(diff) = compare_xml(expected, actual) {
146 panic!("{diff}");
147 }
148}
149
150pub fn assert_matches_golden(
152 root: impl AsRef<Path>,
153 golden_name: impl AsRef<Path>,
154 actual: &str,
155) -> XmlResult<()> {
156 let expected = read_golden(root, golden_name)?;
157 assert_xml_eq(&expected, actual);
158 Ok(())
159}
160
161pub fn assert_compact_roundtrip(xml: &str) -> XmlResult<RoundtripResult> {
163 assert_compact_roundtrip_with_config(xml, &ParserConfig::default())
164}
165
166pub fn assert_compact_roundtrip_with_config(
168 xml: &str,
169 config: &ParserConfig,
170) -> XmlResult<RoundtripResult> {
171 let original = parse_str_with_config(xml, config)?;
172 let compact_xml = to_string_compact(&original)?;
173 let reparsed = parse_str_with_config(&compact_xml, config)?;
174 let reserialized_xml = to_string_compact(&reparsed)?;
175
176 if compact_xml != reserialized_xml {
177 return Err(XmlError::new(
178 ErrorKind::InvalidOperation,
179 XmlDiff::new(&compact_xml, &reserialized_xml).to_string(),
180 ));
181 }
182
183 Ok(RoundtripResult {
184 original,
185 compact_xml,
186 reparsed,
187 reserialized_xml,
188 })
189}
190
191pub fn assert_document_matches_golden(
193 root: impl AsRef<Path>,
194 golden_name: impl AsRef<Path>,
195 document: &Document,
196 config: &WriterConfig,
197) -> XmlResult<()> {
198 let actual = to_string_with_config(document, config)?;
199 assert_matches_golden(root, golden_name, &actual)
200}
201
202pub fn parse_xml_fixture(root: impl AsRef<Path>, name: impl AsRef<Path>) -> XmlResult<Document> {
204 let xml = read_xml_fixture(root, name)?;
205 parse_str(&xml)
206}
207
208fn without_final_line_ending(value: &str) -> &str {
209 value
210 .strip_suffix("\r\n")
211 .or_else(|| value.strip_suffix('\n'))
212 .unwrap_or(value)
213}
214
215fn diff_summary(expected: &str, actual: &str) -> String {
216 let expected_lines: Vec<_> = expected.lines().collect();
217 let actual_lines: Vec<_> = actual.lines().collect();
218 let line_count = expected_lines.len().max(actual_lines.len());
219
220 for index in 0..line_count {
221 let expected_line = expected_lines.get(index).copied().unwrap_or("");
222 let actual_line = actual_lines.get(index).copied().unwrap_or("");
223 if expected_line != actual_line {
224 let column = first_different_column(expected_line, actual_line);
225 return format!(
226 "first difference at line {}, column {}\nexpected line: {}\nactual line: {}",
227 index + 1,
228 column,
229 expected_line,
230 actual_line
231 );
232 }
233 }
234
235 format!(
236 "length differs: expected {} bytes, actual {} bytes",
237 expected.len(),
238 actual.len()
239 )
240}
241
242fn first_different_column(expected: &str, actual: &str) -> usize {
243 let mut expected_chars = expected.chars();
244 let mut actual_chars = actual.chars();
245 let mut column = 1;
246
247 loop {
248 match (expected_chars.next(), actual_chars.next()) {
249 (Some(left), Some(right)) if left == right => column += 1,
250 _ => return column,
251 }
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn testing_compare_xml_allows_final_fixture_newline() {
261 compare_xml("<Root/>\n", "<Root/>").expect("only final newline differs");
262 }
263
264 #[test]
265 fn testing_compare_xml_reports_first_different_line() {
266 let error = compare_xml("<Root>\n <A/>\n</Root>", "<Root>\n <B/>\n</Root>")
267 .expect_err("XML must differ");
268
269 assert!(error.summary().contains("line 2"));
270 assert!(error.summary().contains("<A/>"));
271 assert!(error.summary().contains("<B/>"));
272 }
273
274 #[test]
275 fn testing_compact_roundtrip_stabilizes_parser_writer_output() -> XmlResult<()> {
276 let result = assert_compact_roundtrip("<Root><Item>A</Item><Item>B</Item></Root>")?;
277
278 assert_eq!(
279 result.compact_xml(),
280 "<Root><Item>A</Item><Item>B</Item></Root>"
281 );
282 assert_eq!(result.compact_xml(), result.reserialized_xml());
283 assert_eq!(result.original(), result.reparsed());
284 Ok(())
285 }
286}