LeVCS/crates/levcs-merge/src/format_extra.rs

935 lines
32 KiB
Rust

//! Format handlers that round out §6.3.1: YAML, Markdown, prose, XML.
//!
//! YAML reuses the same recursive value merge as the JSON handler, parsing
//! each side via `serde_yaml::Value` and bridging through `serde_json::Value`.
//!
//! Markdown is split into top-level sections by ATX heading; sections with a
//! shared heading are reconciled with the textual handler's diff3 merge,
//! while disjoint sections are kept independently. Sections without headings
//! (preamble, lists between headings) are treated as anonymous blocks keyed
//! by content hash, mirroring the tree-sitter handler's anonymous-block
//! handling.
//!
//! Prose is paragraph-aware: paragraphs separated by blank lines are merged
//! as identity-keyed blocks (paragraph text is its own key, so identical
//! paragraphs deduplicate; modified paragraphs that diverge become
//! conflicts). The spec describes a CRDT representation; this implementation
//! approximates that with a paragraph-level three-way merge — sufficient for
//! prose text where paragraph reordering is rare.
//!
//! XML treats each element as a structural node and merges children
//! recursively. Whitespace-only text nodes are not significant. Element
//! identity is `(tag-name, sorted-attributes, child-order-index)`; this is a
//! best-effort match that handles disjoint additions to different elements
//! without conflict.
use std::path::Path;
use serde_json::Value;
use crate::format::merge_value;
use crate::handler::{ConflictRegion, MergeHandler, MergeNote, MergeResult, MergeStatus};
use crate::textual::TextualHandler;
pub struct YamlHandler;
pub struct MarkdownHandler;
pub struct ProseHandler;
pub struct XmlHandler;
// ---------------------------------------------------------------------------
// YAML
// ---------------------------------------------------------------------------
fn yaml_to_json(v: serde_yaml::Value) -> Value {
use serde_yaml::Value as Y;
match v {
Y::Null => Value::Null,
Y::Bool(b) => Value::Bool(b),
Y::Number(n) => {
if let Some(i) = n.as_i64() {
Value::from(i)
} else if let Some(f) = n.as_f64() {
serde_json::Number::from_f64(f)
.map(Value::Number)
.unwrap_or(Value::Null)
} else {
Value::Null
}
}
Y::String(s) => Value::String(s),
Y::Sequence(seq) => Value::Array(seq.into_iter().map(yaml_to_json).collect()),
Y::Mapping(map) => {
let mut obj = serde_json::Map::new();
for (k, v) in map {
let key = match k {
Y::String(s) => s,
other => serde_yaml::to_string(&other).unwrap_or_default(),
};
obj.insert(key, yaml_to_json(v));
}
Value::Object(obj)
}
Y::Tagged(t) => yaml_to_json(t.value),
}
}
fn json_to_yaml(v: &Value) -> serde_yaml::Value {
use serde_yaml::Value as Y;
match v {
Value::Null => Y::Null,
Value::Bool(b) => Y::Bool(*b),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Y::Number(i.into())
} else if let Some(f) = n.as_f64() {
Y::Number(f.into())
} else {
Y::Null
}
}
Value::String(s) => Y::String(s.clone()),
Value::Array(a) => Y::Sequence(a.iter().map(json_to_yaml).collect()),
Value::Object(o) => {
let mut m = serde_yaml::Mapping::new();
for (k, v) in o {
m.insert(Y::String(k.clone()), json_to_yaml(v));
}
Y::Mapping(m)
}
}
}
impl MergeHandler for YamlHandler {
fn name(&self) -> &str {
"yaml"
}
fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("yaml") | Some("yml")
)
}
fn merge(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let parse = |b: &[u8]| -> Option<Value> {
let v: serde_yaml::Value = serde_yaml::from_slice(b).ok()?;
Some(yaml_to_json(v))
};
let (b, o, t) = match (parse(base), parse(ours), parse(theirs)) {
(Some(b), Some(o), Some(t)) => (b, o, t),
_ => {
return MergeResult {
handler: self.name().into(),
status: MergeStatus::NotApplicable,
};
}
};
let (merged, conflicts) = merge_value(&b, &o, &t, "");
let bytes = serde_yaml::to_string(&json_to_yaml(&merged))
.unwrap_or_default()
.into_bytes();
let status = if conflicts.is_empty() {
MergeStatus::Merged {
content: bytes,
notes: vec![MergeNote {
message: "structural YAML three-way merge".into(),
}],
}
} else {
MergeStatus::Conflict {
regions: conflicts,
partial: bytes,
}
};
MergeResult {
handler: self.name().into(),
status,
}
}
}
// ---------------------------------------------------------------------------
// Markdown
// ---------------------------------------------------------------------------
#[derive(Clone, Debug)]
struct MdSection {
/// `None` for the preamble (before the first heading) or anonymous body.
heading: Option<String>,
text: String,
}
fn split_markdown(src: &str) -> Vec<MdSection> {
let mut out: Vec<MdSection> = Vec::new();
let mut cur = MdSection {
heading: None,
text: String::new(),
};
for line in src.split_inclusive('\n') {
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
if !cur.text.is_empty() || cur.heading.is_some() {
out.push(std::mem::replace(
&mut cur,
MdSection {
heading: None,
text: String::new(),
},
));
}
// Extract heading text (without leading #s).
let head = trimmed
.trim_start_matches('#')
.trim()
.trim_end_matches('\n')
.to_string();
cur.heading = Some(head);
cur.text.push_str(line);
} else {
cur.text.push_str(line);
}
}
if !cur.text.is_empty() || cur.heading.is_some() {
out.push(cur);
}
out
}
impl MergeHandler for MarkdownHandler {
fn name(&self) -> &str {
"markdown"
}
fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("md") | Some("markdown")
)
}
fn merge(&self, path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let parse = |b: &[u8]| -> Option<Vec<MdSection>> {
std::str::from_utf8(b).ok().map(split_markdown)
};
let (b, o, t) = match (parse(base), parse(ours), parse(theirs)) {
(Some(b), Some(o), Some(t)) => (b, o, t),
_ => {
return MergeResult {
handler: self.name().into(),
status: MergeStatus::NotApplicable,
};
}
};
let key = |s: &MdSection| -> String {
match &s.heading {
Some(h) => format!("h:{h}"),
None => format!("anon:{}", blake3::hash(s.text.as_bytes()).to_hex()),
}
};
let lookup = |list: &[MdSection], k: &str| -> Option<MdSection> {
list.iter().find(|s| key(s) == *k).cloned()
};
let mut emitted: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut output: Vec<String> = Vec::new();
let mut conflicts: Vec<ConflictRegion> = Vec::new();
let textual = TextualHandler;
for s in &o {
let k = key(s);
if !emitted.insert(k.clone()) {
continue;
}
let bs = lookup(&b, &k);
let ts = lookup(&t, &k);
match (bs, ts) {
(None, None) => output.push(s.text.clone()),
(None, Some(ts)) => {
if s.text == ts.text {
output.push(s.text.clone());
} else if let Some(h) = &s.heading {
let res = textual.merge(path, b"", s.text.as_bytes(), ts.text.as_bytes());
match res.status {
MergeStatus::Merged { content, .. } => {
output.push(String::from_utf8_lossy(&content).to_string());
}
MergeStatus::Conflict { partial, regions } => {
output.push(String::from_utf8_lossy(&partial).to_string());
for mut r in regions {
r.description = format!("section '{h}': {}", r.description);
conflicts.push(r);
}
}
MergeStatus::NotApplicable => output.push(s.text.clone()),
}
} else {
output.push(s.text.clone());
output.push(ts.text.clone());
}
}
(Some(bs), None) => {
// Section deleted in theirs.
if s.text == bs.text {
// unchanged ours, deleted theirs → drop
} else {
output.push(s.text.clone());
conflicts.push(ConflictRegion {
description: format!(
"section '{}' modified vs deleted",
s.heading.clone().unwrap_or_default()
),
base: 0..0,
ours: 0..0,
theirs: 0..0,
});
}
}
(Some(bs), Some(ts)) => {
let ours_changed = s.text != bs.text;
let theirs_changed = ts.text != bs.text;
match (ours_changed, theirs_changed) {
(false, false) => output.push(s.text.clone()),
(true, false) => output.push(s.text.clone()),
(false, true) => output.push(ts.text.clone()),
(true, true) => {
if s.text == ts.text {
output.push(s.text.clone());
} else {
let res = textual.merge(
path,
bs.text.as_bytes(),
s.text.as_bytes(),
ts.text.as_bytes(),
);
match res.status {
MergeStatus::Merged { content, .. } => {
output.push(String::from_utf8_lossy(&content).to_string());
}
MergeStatus::Conflict { partial, regions } => {
output.push(String::from_utf8_lossy(&partial).to_string());
let h = s.heading.clone().unwrap_or_default();
for mut r in regions {
r.description =
format!("section '{h}': {}", r.description);
conflicts.push(r);
}
}
MergeStatus::NotApplicable => output.push(s.text.clone()),
}
}
}
}
}
}
}
for s in &t {
let k = key(s);
if emitted.contains(&k) {
continue;
}
emitted.insert(k.clone());
match lookup(&b, &k) {
None => output.push(s.text.clone()),
Some(bs) => {
if s.text == bs.text {
// theirs unchanged, ours deleted → drop
} else {
output.push(s.text.clone());
conflicts.push(ConflictRegion {
description: format!(
"section '{}' deleted by ours, modified by theirs",
s.heading.clone().unwrap_or_default()
),
base: 0..0,
ours: 0..0,
theirs: 0..0,
});
}
}
}
}
let merged = output.concat();
let status = if conflicts.is_empty() {
MergeStatus::Merged {
content: merged.into_bytes(),
notes: vec![MergeNote {
message: "section-based markdown merge".into(),
}],
}
} else {
MergeStatus::Conflict {
regions: conflicts,
partial: merged.into_bytes(),
}
};
MergeResult {
handler: self.name().into(),
status,
}
}
}
// ---------------------------------------------------------------------------
// Prose (paragraph-aware textual)
// ---------------------------------------------------------------------------
fn split_paragraphs(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
for line in s.split_inclusive('\n') {
if line.trim().is_empty() {
cur.push_str(line);
if !cur.trim().is_empty() {
out.push(std::mem::take(&mut cur));
} else {
cur.clear();
}
} else {
cur.push_str(line);
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
impl MergeHandler for ProseHandler {
fn name(&self) -> &str {
"prose"
}
fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool {
matches!(path.extension().and_then(|e| e.to_str()), Some("txt"))
}
fn merge(&self, path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let to_paras =
|b: &[u8]| -> Option<Vec<String>> { std::str::from_utf8(b).ok().map(split_paragraphs) };
let (b, o, t) = match (to_paras(base), to_paras(ours), to_paras(theirs)) {
(Some(b), Some(o), Some(t)) => (b, o, t),
_ => {
return MergeResult {
handler: self.name().into(),
status: MergeStatus::NotApplicable,
};
}
};
let key = |p: &str| -> String { blake3::hash(p.trim().as_bytes()).to_hex().to_string() };
// Paragraph identity is content-only, so a modification looks like
// delete+add. To avoid silently concatenating two divergent edits
// (which would lose their conflict), check that every base paragraph
// is preserved in ours or theirs. If any base paragraph is gone from
// both sides, the divergent edits could conflict — fall through to
// textual diff3 for safety.
let preserved = b.iter().all(|p| {
let k = key(p);
o.iter().any(|x| key(x) == k) || t.iter().any(|x| key(x) == k)
});
if !preserved {
let textual = TextualHandler;
let mut res = textual.merge(path, base, ours, theirs);
res.handler = self.name().into();
return res;
}
let mut emitted: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut out: Vec<String> = Vec::new();
for p in &o {
let k = key(p);
if !emitted.insert(k.clone()) {
continue;
}
let in_base = b.iter().any(|x| key(x) == k);
let in_theirs = t.iter().any(|x| key(x) == k);
if in_base || in_theirs {
out.push(p.clone());
} else {
out.push(p.clone());
}
}
for p in &t {
let k = key(p);
if emitted.contains(&k) {
continue;
}
emitted.insert(k.clone());
let in_base = b.iter().any(|x| key(x) == k);
if !in_base {
// Independently added paragraph in theirs.
out.push(p.clone());
} else {
// Theirs's paragraph existed in base but not in ours → ours deleted it.
// Honour the deletion (drop).
}
}
// Detect deleted-in-theirs paragraphs: paragraphs in base but in
// neither ours nor theirs were deleted by both, so already absent.
// Paragraphs in base+ours but not theirs were deleted by theirs and
// already absent from `out` only if ours's iteration didn't keep
// them — but we always keep ours. Treat as ours-wins (keep).
let merged = out.concat();
MergeResult {
handler: self.name().into(),
status: MergeStatus::Merged {
content: merged.into_bytes(),
notes: vec![MergeNote {
message: "paragraph-level prose merge".into(),
}],
},
}
}
}
// ---------------------------------------------------------------------------
// XML
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq, Eq)]
enum XmlNode {
Element {
name: String,
attrs: Vec<(String, String)>,
children: Vec<XmlNode>,
},
Text(String),
}
fn xml_parse(src: &[u8]) -> Option<Vec<XmlNode>> {
use quick_xml::events::Event;
use quick_xml::Reader;
let mut reader = Reader::from_reader(src);
reader.config_mut().trim_text(false);
let mut buf = Vec::new();
let mut stack: Vec<(String, Vec<(String, String)>, Vec<XmlNode>)> = Vec::new();
let mut top: Vec<XmlNode> = Vec::new();
loop {
match reader.read_event_into(&mut buf).ok()? {
Event::Start(e) => {
let name = std::str::from_utf8(e.name().as_ref()).ok()?.to_string();
let mut attrs = Vec::new();
for a in e.attributes().with_checks(false).flatten() {
let k = std::str::from_utf8(a.key.as_ref()).ok()?.to_string();
let v = a.unescape_value().ok()?.to_string();
attrs.push((k, v));
}
attrs.sort();
stack.push((name, attrs, Vec::new()));
}
Event::End(_) => {
let (name, attrs, children) = stack.pop()?;
let node = XmlNode::Element {
name,
attrs,
children,
};
if let Some(parent) = stack.last_mut() {
parent.2.push(node);
} else {
top.push(node);
}
}
Event::Empty(e) => {
let name = std::str::from_utf8(e.name().as_ref()).ok()?.to_string();
let mut attrs = Vec::new();
for a in e.attributes().with_checks(false).flatten() {
let k = std::str::from_utf8(a.key.as_ref()).ok()?.to_string();
let v = a.unescape_value().ok()?.to_string();
attrs.push((k, v));
}
attrs.sort();
let node = XmlNode::Element {
name,
attrs,
children: Vec::new(),
};
if let Some(parent) = stack.last_mut() {
parent.2.push(node);
} else {
top.push(node);
}
}
Event::Text(t) => {
let s = std::str::from_utf8(t.as_ref()).ok()?.to_string();
let node = XmlNode::Text(s);
if let Some(parent) = stack.last_mut() {
parent.2.push(node);
} else {
top.push(node);
}
}
Event::Eof => break,
_ => {}
}
buf.clear();
}
Some(top)
}
fn xml_serialize(nodes: &[XmlNode]) -> String {
let mut out = String::new();
for n in nodes {
write_node(n, &mut out);
}
out
}
fn write_node(n: &XmlNode, out: &mut String) {
match n {
XmlNode::Text(t) => {
out.push_str(&xml_escape_text(t));
}
XmlNode::Element {
name,
attrs,
children,
} => {
out.push('<');
out.push_str(name);
for (k, v) in attrs {
out.push(' ');
out.push_str(k);
out.push_str("=\"");
out.push_str(&xml_escape_attr(v));
out.push('"');
}
if children.is_empty() {
out.push_str("/>");
} else {
out.push('>');
for c in children {
write_node(c, out);
}
out.push_str("</");
out.push_str(name);
out.push('>');
}
}
}
}
fn xml_escape_text(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;")
}
fn xml_escape_attr(s: &str) -> String {
s.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
}
fn merge_xml_children(
base: &[XmlNode],
ours: &[XmlNode],
theirs: &[XmlNode],
path: &str,
) -> (Vec<XmlNode>, Vec<ConflictRegion>) {
if ours == theirs {
return (ours.to_vec(), Vec::new());
}
if base == ours {
return (theirs.to_vec(), Vec::new());
}
if base == theirs {
return (ours.to_vec(), Vec::new());
}
// Sequence-aware merge: iterate by element name + position. Disjoint
// additions of differently-named siblings stay non-conflicting; same-
// name siblings recurse.
let mut conflicts = Vec::new();
let max_len = base.len().max(ours.len()).max(theirs.len());
let mut merged: Vec<XmlNode> = Vec::new();
for i in 0..max_len {
let bv = base.get(i);
let ov = ours.get(i);
let tv = theirs.get(i);
match (bv, ov, tv) {
(None, None, None) => {}
(None, Some(o), None) => merged.push(o.clone()),
(None, None, Some(t)) => merged.push(t.clone()),
(Some(_), None, None) => {}
(Some(b), Some(o), None) => {
if b == o { /* deleted in theirs */
} else {
merged.push(o.clone());
conflicts.push(ConflictRegion {
description: format!("{path}[{i}]: modified by ours, deleted by theirs"),
base: 0..0,
ours: 0..0,
theirs: 0..0,
});
}
}
(Some(b), None, Some(t)) => {
if b == t { /* deleted in ours */
} else {
merged.push(t.clone());
conflicts.push(ConflictRegion {
description: format!("{path}[{i}]: deleted by ours, modified by theirs"),
base: 0..0,
ours: 0..0,
theirs: 0..0,
});
}
}
(None, Some(o), Some(t)) => {
if o == t {
merged.push(o.clone());
} else {
merged.push(o.clone());
conflicts.push(ConflictRegion {
description: format!(
"{path}[{i}]: independently added with different values"
),
base: 0..0,
ours: 0..0,
theirs: 0..0,
});
}
}
(Some(b), Some(o), Some(t)) => {
let (m, c) = merge_xml_node(b, o, t, &format!("{path}[{i}]"));
merged.push(m);
conflicts.extend(c);
}
}
}
(merged, conflicts)
}
fn merge_xml_node(
base: &XmlNode,
ours: &XmlNode,
theirs: &XmlNode,
path: &str,
) -> (XmlNode, Vec<ConflictRegion>) {
if ours == theirs {
return (ours.clone(), Vec::new());
}
if base == ours {
return (theirs.clone(), Vec::new());
}
if base == theirs {
return (ours.clone(), Vec::new());
}
match (base, ours, theirs) {
(
XmlNode::Element {
name: bn,
attrs: ba,
children: bc,
},
XmlNode::Element {
name: on,
attrs: oa,
children: oc,
},
XmlNode::Element {
name: tn,
attrs: ta,
children: tc,
},
) if bn == on && on == tn => {
// Merge attributes structurally via JSON.
let to_obj = |v: &[(String, String)]| -> Value {
let mut m = serde_json::Map::new();
for (k, val) in v {
m.insert(k.clone(), Value::String(val.clone()));
}
Value::Object(m)
};
let (am, ac) = merge_value(&to_obj(ba), &to_obj(oa), &to_obj(ta), &format!("{path}.@"));
let attrs: Vec<(String, String)> = match am {
Value::Object(m) => {
let mut v: Vec<_> = m
.into_iter()
.map(|(k, val)| (k, val.as_str().unwrap_or("").to_string()))
.collect();
v.sort();
v
}
_ => oa.clone(),
};
let (cm, cc) = merge_xml_children(bc, oc, tc, &format!("{path}/{on}"));
let mut conflicts = ac;
conflicts.extend(cc);
(
XmlNode::Element {
name: on.clone(),
attrs,
children: cm,
},
conflicts,
)
}
_ => {
// Tag-name change or text/element type mismatch — flat conflict.
(
ours.clone(),
vec![ConflictRegion {
description: format!("{path}: structural mismatch between ours and theirs"),
base: 0..0,
ours: 0..0,
theirs: 0..0,
}],
)
}
}
}
impl MergeHandler for XmlHandler {
fn name(&self) -> &str {
"xml"
}
fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("xml") | Some("svg") | Some("html")
)
}
fn merge(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let (b, o, t) = match (xml_parse(base), xml_parse(ours), xml_parse(theirs)) {
(Some(b), Some(o), Some(t)) => (b, o, t),
_ => {
return MergeResult {
handler: self.name().into(),
status: MergeStatus::NotApplicable,
};
}
};
let (merged, conflicts) = merge_xml_children(&b, &o, &t, "");
let s = xml_serialize(&merged);
let bytes = s.into_bytes();
let status = if conflicts.is_empty() {
MergeStatus::Merged {
content: bytes,
notes: vec![MergeNote {
message: "structural XML merge".into(),
}],
}
} else {
MergeStatus::Conflict {
regions: conflicts,
partial: bytes,
}
};
MergeResult {
handler: self.name().into(),
status,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn yaml_disjoint_keys_merge() {
let h = YamlHandler;
let base = b"a: 1\n";
let ours = b"a: 1\nb: 2\n";
let theirs = b"a: 1\nc: 3\n";
let r = h.merge(Path::new("x.yaml"), base, ours, theirs);
match r.status {
MergeStatus::Merged { content, .. } => {
let s = String::from_utf8_lossy(&content);
assert!(s.contains("a:"));
assert!(s.contains("b:"));
assert!(s.contains("c:"));
}
other => panic!("expected merged, got {other:?}"),
}
}
#[test]
fn yaml_scalar_conflict() {
let h = YamlHandler;
let base = b"x: 1\n";
let ours = b"x: 2\n";
let theirs = b"x: 3\n";
let r = h.merge(Path::new("x.yml"), base, ours, theirs);
assert!(matches!(r.status, MergeStatus::Conflict { .. }));
}
#[test]
fn markdown_disjoint_section_additions_merge() {
let h = MarkdownHandler;
let base = b"# Intro\n\nHello.\n";
let ours = b"# Intro\n\nHello.\n\n# A\n\nours.\n";
let theirs = b"# Intro\n\nHello.\n\n# B\n\ntheirs.\n";
let r = h.merge(Path::new("x.md"), base, ours, theirs);
match r.status {
MergeStatus::Merged { content, .. } => {
let s = String::from_utf8_lossy(&content);
assert!(s.contains("# A"));
assert!(s.contains("# B"));
}
other => panic!("expected merged, got {other:?}"),
}
}
#[test]
fn markdown_same_section_diverges_to_conflict() {
let h = MarkdownHandler;
let base = b"# A\n\nbase.\n";
let ours = b"# A\n\nours.\n";
let theirs = b"# A\n\ntheirs.\n";
let r = h.merge(Path::new("x.md"), base, ours, theirs);
assert!(matches!(r.status, MergeStatus::Conflict { .. }));
}
#[test]
fn prose_independently_added_paragraphs_merge() {
let h = ProseHandler;
let base = b"para one.\n";
let ours = b"para one.\n\nours added.\n";
let theirs = b"para one.\n\ntheirs added.\n";
let r = h.merge(Path::new("x.txt"), base, ours, theirs);
match r.status {
MergeStatus::Merged { content, .. } => {
let s = String::from_utf8_lossy(&content);
assert!(s.contains("para one."));
assert!(s.contains("ours added."));
assert!(s.contains("theirs added."));
}
other => panic!("expected merged, got {other:?}"),
}
}
#[test]
fn xml_disjoint_attrs_merge() {
let h = XmlHandler;
let base = br#"<root><a x="1"/></root>"#;
let ours = br#"<root><a x="1" y="2"/></root>"#;
let theirs = br#"<root><a x="1" z="3"/></root>"#;
let r = h.merge(Path::new("x.xml"), base, ours, theirs);
match r.status {
MergeStatus::Merged { content, .. } => {
let s = String::from_utf8_lossy(&content);
assert!(s.contains("y=\"2\""));
assert!(s.contains("z=\"3\""));
}
other => panic!("expected merged, got {other:?}"),
}
}
#[test]
fn xml_scalar_attr_conflict() {
let h = XmlHandler;
let base = br#"<a x="1"/>"#;
let ours = br#"<a x="2"/>"#;
let theirs = br#"<a x="3"/>"#;
let r = h.merge(Path::new("x.xml"), base, ours, theirs);
assert!(matches!(r.status, MergeStatus::Conflict { .. }));
}
}