Skip to main content

fml_xml/
compare.rs

1//! Whether two XML documents say the same: the same elements and attributes in the same
2//! namespaces, the same text; elements of one name in the same order (the order of differently
3//! named siblings is the schema's business, and the writer always uses it). Prefixes, namespace declarations, attribute
4//! order, comments, processing instructions, `xsi:schemaLocation` and whitespace between elements
5//! do not count; an `xsi:type` compares by the type it names, not the prefix it is written with,
6//! and numbers by value (`12.50` is `12.5`).
7
8use roxmltree::{Document, Node, ParsingOptions};
9
10use crate::{Error, XSI_NS};
11
12/// A place where two documents differ.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Difference {
15    /// The element, as `/ClinicalDocument/component/structuredBody/component[3]/section`.
16    pub path: String,
17    pub message: String,
18}
19
20impl std::fmt::Display for Difference {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        write!(f, "{}: {}", self.path, self.message)
23    }
24}
25
26/// The differences between `expected` and `actual`; empty when they say the same.
27pub fn compare(expected: &str, actual: &str) -> Result<Vec<Difference>, Error> {
28    let expected = parse(expected, "expected")?;
29    let actual = parse(actual, "actual")?;
30    let mut differences = Vec::new();
31    let a = expected.root_element();
32    let b = actual.root_element();
33    let path = format!("/{}", a.tag_name().name());
34    compare_elements(a, b, &path, &mut differences);
35    Ok(differences)
36}
37
38/// [`compare`] for fragments that use namespace prefixes they do not declare: both are read with
39/// `namespaces` in scope (see [`ReadOptions::namespaces`](crate::ReadOptions)).
40pub fn compare_fragments(
41    expected: &str,
42    actual: &str,
43    namespaces: &[(&str, &str)],
44) -> Result<Vec<Difference>, Error> {
45    let (expected, _) = crate::read::wrap_fragment(expected, namespaces);
46    let (actual, _) = crate::read::wrap_fragment(actual, namespaces);
47    let expected = parse(&expected, "expected")?;
48    let actual = parse(&actual, "actual")?;
49    let mut differences = Vec::new();
50    match (first_element(&expected), first_element(&actual)) {
51        (Some(a), Some(b)) => {
52            let path = format!("/{}", a.tag_name().name());
53            compare_elements(a, b, &path, &mut differences);
54        }
55        _ => differences.push(Difference {
56            path: "/".to_owned(),
57            message: "a fragment has no element".to_owned(),
58        }),
59    }
60    Ok(differences)
61}
62
63fn first_element<'a, 'input>(doc: &'a Document<'input>) -> Option<Node<'a, 'input>> {
64    doc.root_element().children().find(|n| n.is_element())
65}
66
67fn parse<'input>(text: &'input str, which: &str) -> Result<Document<'input>, Error> {
68    let options = ParsingOptions {
69        allow_dtd: false,
70        ..ParsingOptions::default()
71    };
72    Document::parse_with_options(text, options).map_err(|e| Error {
73        message: format!("{which}: not well-formed XML: {e}"),
74        position: None,
75    })
76}
77
78type Name = (Option<String>, String);
79
80fn name_of(node: Node<'_, '_>) -> Name {
81    (
82        node.tag_name().namespace().map(str::to_owned),
83        node.tag_name().name().to_owned(),
84    )
85}
86
87fn show(name: &Name) -> String {
88    match &name.0 {
89        Some(ns) => format!("{{{ns}}}{}", name.1),
90        None => name.1.clone(),
91    }
92}
93
94/// Attributes that count, as (name, value), sorted; `xsi:type` resolved to `{ns}name`.
95fn attributes(node: Node<'_, '_>) -> Vec<(Name, String)> {
96    let mut out: Vec<(Name, String)> = node
97        .attributes()
98        .filter(|a| !(a.namespace() == Some(XSI_NS) && a.name() == "schemaLocation"))
99        .map(|a| {
100            let name = (a.namespace().map(str::to_owned), a.name().to_owned());
101            let value = if a.namespace() == Some(XSI_NS) && a.name() == "type" {
102                let (prefix, local) = match a.value().split_once(':') {
103                    Some((p, l)) => (Some(p), l),
104                    None => (None, a.value()),
105                };
106                match node.lookup_namespace_uri(prefix) {
107                    Some(ns) => format!("{{{ns}}}{local}"),
108                    None => local.to_owned(),
109                }
110            } else {
111                a.value().to_owned()
112            };
113            (name, value)
114        })
115        .collect();
116    out.sort();
117    out
118}
119
120/// Child elements in order, and the text of the element: its text pieces trimmed and joined.
121/// Whitespace-only text does not count, nor does where text sits between child elements (a
122/// reference before or after the text of an `ED` says the same).
123fn content<'a, 'input>(node: Node<'a, 'input>) -> (Vec<Node<'a, 'input>>, String) {
124    let mut elements = Vec::new();
125    let mut texts: Vec<&str> = Vec::new();
126    let has_elements = node.children().any(|c| c.is_element());
127    let mut whole = String::new();
128    for child in node.children() {
129        if child.is_element() {
130            elements.push(child);
131        } else if child.is_text() {
132            let text = child.text().unwrap_or_default();
133            whole.push_str(text);
134            if !text.trim().is_empty() {
135                texts.push(text.trim());
136            }
137        }
138    }
139    let text = if has_elements {
140        texts.join(" ")
141    } else if whole.trim().is_empty() {
142        String::new()
143    } else {
144        whole
145    };
146    (elements, text)
147}
148
149fn compare_elements(a: Node<'_, '_>, b: Node<'_, '_>, path: &str, out: &mut Vec<Difference>) {
150    let (name_a, name_b) = (name_of(a), name_of(b));
151    if name_a != name_b {
152        out.push(Difference {
153            path: path.to_owned(),
154            message: format!("element `{}` became `{}`", show(&name_a), show(&name_b)),
155        });
156        return;
157    }
158    let (attrs_a, attrs_b) = (attributes(a), attributes(b));
159    for (name, value) in &attrs_a {
160        match attrs_b.iter().find(|(n, _)| n == name) {
161            None => out.push(Difference {
162                path: path.to_owned(),
163                message: format!("attribute `{}=\"{value}\"` is missing", show(name)),
164            }),
165            Some((_, other)) if !same_value(value, other) => out.push(Difference {
166                path: path.to_owned(),
167                message: format!("attribute `{}`: `{value}` became `{other}`", show(name)),
168            }),
169            Some(_) => {}
170        }
171    }
172    for (name, value) in &attrs_b {
173        if !attrs_a.iter().any(|(n, _)| n == name) {
174            out.push(Difference {
175                path: path.to_owned(),
176                message: format!("attribute `{}=\"{value}\"` was added", show(name)),
177            });
178        }
179    }
180    let ((elements_a, text_a), (elements_b, text_b)) = (content(a), content(b));
181    if text_a != text_b {
182        out.push(Difference {
183            path: path.to_owned(),
184            message: match (text_a.is_empty(), text_b.is_empty()) {
185                (false, true) => format!("text `{}` is missing", clip(&text_a)),
186                (true, false) => format!("text `{}` was added", clip(&text_b)),
187                _ => format!("text `{}` became `{}`", clip(&text_a), clip(&text_b)),
188            },
189        });
190    }
191    // Elements compare by name, in order within each name: the order of differently named
192    // siblings is the schema's, not the content's.
193    let groups = |elements: &[Node<'_, '_>]| {
194        let mut groups: Vec<(Name, Vec<usize>)> = Vec::new();
195        for (index, element) in elements.iter().enumerate() {
196            let name = name_of(*element);
197            match groups.iter_mut().find(|(n, _)| *n == name) {
198                Some((_, indexes)) => indexes.push(index),
199                None => groups.push((name, vec![index])),
200            }
201        }
202        groups
203    };
204    let (groups_a, groups_b) = (groups(&elements_a), groups(&elements_b));
205    for (name, indexes) in &groups_a {
206        let others = groups_b
207            .iter()
208            .find(|(n, _)| n == name)
209            .map(|(_, i)| i.as_slice())
210            .unwrap_or_default();
211        for (n, &index) in indexes.iter().enumerate() {
212            match others.get(n) {
213                Some(&other) => {
214                    let child_path = format!("{path}/{}[{}]", name.1, n + 1);
215                    compare_elements(elements_a[index], elements_b[other], &child_path, out);
216                }
217                None => out.push(Difference {
218                    path: path.to_owned(),
219                    message: format!("element `{}` [{}] is missing", show(name), n + 1),
220                }),
221            }
222        }
223        for n in indexes.len()..others.len() {
224            out.push(Difference {
225                path: path.to_owned(),
226                message: format!("element `{}` [{}] was added", show(name), n + 1),
227            });
228        }
229    }
230    for (name, indexes) in &groups_b {
231        if !groups_a.iter().any(|(n, _)| n == name) {
232            out.push(Difference {
233                path: path.to_owned(),
234                message: format!(
235                    "element `{}` was added ({} times)",
236                    show(name),
237                    indexes.len()
238                ),
239            });
240        }
241    }
242}
243
244/// Attribute values are the same when equal as text, or as decimal numbers (`12.50` and `12.5`:
245/// the JSON form keeps numbers, not how they were written).
246fn same_value(a: &str, b: &str) -> bool {
247    if a == b {
248        return true;
249    }
250    let number = |s: &str| {
251        let s = s.trim();
252        let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
253        (!digits.is_empty() && digits.bytes().all(|c| c.is_ascii_digit() || c == b'.'))
254            .then(|| s.parse::<f64>().ok())
255            .flatten()
256    };
257    matches!((number(a), number(b)), (Some(x), Some(y)) if x == y)
258}
259
260fn clip(text: &str) -> String {
261    let text = text.trim();
262    if text.chars().count() > 60 {
263        format!("{}…", text.chars().take(60).collect::<String>())
264    } else {
265        text.to_owned()
266    }
267}