use alloc::{borrow::Cow, vec::Vec};
use crate::{
param::VcardParam,
prop::VcardProp,
tree::{
codec::mode::VcardEscaper,
cst::VcardCst,
merge::{diff::Diff, instance::Instance, matching::Matching, merger::Merger},
},
value::VcardValue,
};
mod compare;
mod diff;
mod instance;
mod matching;
mod merger;
mod slot;
pub struct VcardMerge<'a> {
pub base: &'a VcardCst<'a>,
pub left: &'a VcardCst<'a>,
pub right: &'a VcardCst<'a>,
}
impl<'a> VcardMerge<'a> {
pub fn merge(self) -> VcardMergeReport<'a> {
let base = Instance::all(self.base);
let left = Instance::all(self.left);
let right = Instance::all(self.right);
let left_matching = Matching::new(&base, &left);
let right_matching = Matching::new(&base, &right);
let left_ops = Diff {
base: &base,
side: &left,
matching: &left_matching,
}
.run();
let right_ops = Diff {
base: &base,
side: &right,
matching: &right_matching,
}
.run();
let mut merger = Merger {
escaper: VcardEscaper::for_version(self.left.version()),
base_instances: &base,
left_instances: &left,
right_instances: &right,
left_matching: &left_matching,
right_matching: &right_matching,
left_ops: &left_ops,
merged: self.left.clone(),
conflicts: Vec::new(),
removals: Vec::new(),
additions: Vec::new(),
readded: Vec::new(),
};
for (target, action) in &right_ops {
merger.apply(target, action);
}
let (merged, conflicts) = merger.finish();
VcardMergeReport {
merged,
left: left_ops.into_iter().map(|(_, action)| action).collect(),
right: right_ops.into_iter().map(|(_, action)| action).collect(),
conflicts,
}
}
}
#[derive(Clone, Debug)]
pub struct VcardMergeReport<'a> {
pub merged: VcardCst<'a>,
pub left: Vec<VcardMergeAction<'a>>,
pub right: Vec<VcardMergeAction<'a>>,
pub conflicts: Vec<VcardMergeConflict<'a>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VcardMergeConflict<'a> {
pub left: VcardMergeAction<'a>,
pub right: VcardMergeAction<'a>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VcardPropPath<'a> {
pub name: Cow<'a, str>,
pub index: usize,
pub identity: Option<Cow<'a, str>>,
}
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VcardMergeAction<'a> {
PropAdded {
at: VcardPropPath<'a>,
prop: VcardProp<'a>,
},
PropRemoved {
at: VcardPropPath<'a>,
prop: VcardProp<'a>,
},
ValueChanged {
at: VcardPropPath<'a>,
old: VcardValue<'a>,
new: VcardValue<'a>,
},
ValueComponentChanged {
at: VcardPropPath<'a>,
component: usize,
old: Vec<Cow<'a, str>>,
new: Vec<Cow<'a, str>>,
},
ValueItemAdded {
at: VcardPropPath<'a>,
item: Cow<'a, str>,
},
ValueItemRemoved {
at: VcardPropPath<'a>,
item: Cow<'a, str>,
},
ParamAdded {
at: VcardPropPath<'a>,
index: usize,
param: VcardParam<'a>,
},
ParamRemoved {
at: VcardPropPath<'a>,
index: usize,
param: VcardParam<'a>,
},
ParamChanged {
at: VcardPropPath<'a>,
index: usize,
old: VcardParam<'a>,
new: VcardParam<'a>,
},
ParamItemAdded {
at: VcardPropPath<'a>,
index: usize,
param: Cow<'a, str>,
item: Cow<'a, str>,
},
ParamItemRemoved {
at: VcardPropPath<'a>,
index: usize,
param: Cow<'a, str>,
item: Cow<'a, str>,
},
}
#[cfg(test)]
mod tests {
use alloc::{borrow::Cow, string::ToString};
use crate::{
tree::{
cst::VcardCst,
merge::{VcardMerge, VcardMergeAction, VcardMergeReport},
},
value::{VcardValue, VcardValueUnknown},
};
fn card(props: &str) -> alloc::string::String {
alloc::format!("BEGIN:VCARD\r\nVERSION:4.0\r\n{props}END:VCARD\r\n")
}
fn components(components: &[&[&str]]) -> VcardValue<'static> {
let components = components
.iter()
.map(|values| {
values
.iter()
.map(|value| Cow::Owned(value.to_string()))
.collect()
})
.collect();
VcardValue::Unknown(VcardValueUnknown { components })
}
fn merge<'a>(
base: &'a VcardCst<'a>,
left: &'a VcardCst<'a>,
right: &'a VcardCst<'a>,
) -> VcardMergeReport<'a> {
VcardMerge { base, left, right }.merge()
}
#[test]
fn merges_disjoint_edits_byte_preservingly() {
let base = card("FN:John Doe\r\nn;pid=1:Doe;John;;;\r\nTITLE:dev\r\nNOTE:hi\r\n");
let left = card("FN:Jane Doe\r\nn;pid=1:Doe;John;;;\r\nTITLE:dev\r\nNOTE:hi\r\n");
let right =
card("FN:John Doe\r\nn;pid=1:Doe;John;;;\r\nTITLE:boss\r\nEMAIL:j@doe.example\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
assert_eq!(
report.merged.to_string(),
card("FN:Jane Doe\r\nn;pid=1:Doe;John;;;\r\nTITLE:boss\r\nEMAIL:j@doe.example\r\n"),
);
assert!(report.conflicts.is_empty(), "{:?}", report.conflicts);
assert_eq!(report.left.len(), 1);
assert!(matches!(
&report.left[0],
VcardMergeAction::ValueChanged { at, .. } if at.name == "FN",
));
assert_eq!(report.right.len(), 3);
assert!(matches!(
&report.right[0],
VcardMergeAction::ValueChanged { at, .. } if at.name == "TITLE",
));
assert!(matches!(
&report.right[1],
VcardMergeAction::PropRemoved { at, .. } if at.name == "NOTE",
));
assert!(matches!(
&report.right[2],
VcardMergeAction::PropAdded { at, .. } if at.name == "EMAIL",
));
}
#[test]
fn identical_edits_do_not_conflict() {
let base = card("FN:A\r\n");
let side = card("FN:B\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&side).unwrap();
let right = VcardCst::parse(&side).unwrap();
let report = merge(&base, &left, &right);
assert!(report.merged.to_string().contains("FN:B\r\n"));
assert!(report.conflicts.is_empty());
}
#[test]
fn divergent_edits_conflict_and_the_left_wins() {
let base = card("FN:A\r\n");
let left = card("FN:B\r\n");
let right = card("FN:C\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
assert!(report.merged.to_string().contains("FN:B\r\n"));
assert_eq!(report.conflicts.len(), 1);
assert!(matches!(
&report.conflicts[0].right,
VcardMergeAction::ValueChanged { at, .. } if at.name == "FN",
));
}
#[test]
fn a_value_change_past_the_first_component_reports_both_values() {
let base = card("NOTE:a;b\r\n");
let left = card("NOTE:a;b\r\n");
let right = card("NOTE:a;CHANGED\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert_eq!(merged, card("NOTE:a;CHANGED\r\n"), "got: {merged}");
assert!(report.conflicts.is_empty(), "{:?}", report.conflicts);
let VcardMergeAction::ValueChanged { old, new, .. } = &report.right[0] else {
panic!("expected a value change, got: {:?}", report.right[0]);
};
assert_eq!(old, &components(&[&["a"], &["b"]]));
assert_eq!(new, &components(&[&["a"], &["CHANGED"]]));
}
#[test]
fn an_update_wins_over_a_removal() {
let base = card("FN:X\r\nNOTE:a\r\n");
let removed = card("FN:X\r\n");
let updated = card("FN:X\r\nNOTE:b\r\n");
let base = VcardCst::parse(&base).unwrap();
let removed = VcardCst::parse(&removed).unwrap();
let updated = VcardCst::parse(&updated).unwrap();
let report = merge(&base, &removed, &updated);
assert!(report.merged.to_string().contains("NOTE:b\r\n"));
assert_eq!(report.conflicts.len(), 1);
let report = merge(&base, &updated, &removed);
assert!(report.merged.to_string().contains("NOTE:b\r\n"));
assert_eq!(report.conflicts.len(), 1);
}
#[test]
fn list_items_merge_as_a_set() {
let base = card("NICKNAME:a,b\r\n");
let left = card("NICKNAME:a,b,c\r\n");
let right = card("NICKNAME:b\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
assert!(report.merged.to_string().contains("NICKNAME:b,c\r\n"));
assert!(report.conflicts.is_empty());
assert!(matches!(
&report.left[0],
VcardMergeAction::ValueItemAdded { item, .. } if item == "c",
));
assert!(matches!(
&report.right[0],
VcardMergeAction::ValueItemRemoved { item, .. } if item == "a",
));
}
#[test]
fn structured_components_merge_one_by_one() {
let base = card("N:Doe;John;;;\r\n");
let left = card("N:Doe;Johnny;;;\r\n");
let right = card("N:Smith;John;;;\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
assert!(report.merged.to_string().contains("N:Smith;Johnny;;;\r\n"));
assert!(report.conflicts.is_empty());
assert!(matches!(
&report.right[0],
VcardMergeAction::ValueComponentChanged { component: 0, .. },
));
}
#[test]
fn params_merge_by_key_and_list_params_per_item() {
let base = card("TEL;TYPE=work:+1\r\n");
let left = card("TEL;TYPE=work;PREF=1:+1\r\n");
let right = card("TEL;TYPE=work,cell:+1\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
assert!(
report
.merged
.to_string()
.contains("TEL;TYPE=work,cell;PREF=1:+1\r\n"),
"got: {}",
report.merged,
);
assert!(report.conflicts.is_empty());
assert!(matches!(
&report.left[0],
VcardMergeAction::ParamAdded { .. },
));
assert!(matches!(
&report.right[0],
VcardMergeAction::ParamItemAdded { param, item, .. }
if param == "TYPE" && item == "cell",
));
}
#[test]
fn pid_identity_survives_a_reorder() {
let base = card("EMAIL;PID=1:a@x\r\nEMAIL;PID=2:b@x\r\n");
let left = card("EMAIL;PID=2:b@x\r\nEMAIL;PID=1:a@x\r\n");
let right = card("EMAIL;PID=1:a@x\r\nEMAIL;PID=2:c@x\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert!(merged.contains("EMAIL;PID=2:c@x\r\n"), "got: {merged}");
assert!(merged.contains("EMAIL;PID=1:a@x\r\n"), "got: {merged}");
assert!(report.conflicts.is_empty());
}
#[test]
fn divergent_additions_of_a_single_valued_property_conflict() {
let base = card("FN:X\r\n");
let left = card("FN:X\r\nUID:urn:a\r\n");
let right = card("FN:X\r\nUID:urn:b\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert!(merged.contains("UID:urn:a\r\n"), "got: {merged}");
assert!(!merged.contains("UID:urn:b\r\n"), "got: {merged}");
assert_eq!(report.conflicts.len(), 1);
}
#[test]
fn divergent_edits_of_an_identity_keyed_property_conflict() {
let base = card("FN:A\r\nTEL:+1\r\n");
let left = card("FN:A\r\nTEL:+2\r\n");
let right = card("FN:A\r\nTEL:+3\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert_eq!(report.conflicts.len(), 1, "{:?}", report.conflicts);
assert!(merged.contains("TEL:+2\r\n"), "got: {merged}");
assert!(
!merged.contains("TEL:+3\r\n"),
"the loser does not join the winner: {merged}",
);
}
#[test]
fn divergent_additions_beside_an_untouched_instance_merge_as_a_set() {
let base = card("FN:A\r\nTEL:+1\r\n");
let left = card("FN:A\r\nTEL:+1\r\nTEL:+2\r\n");
let right = card("FN:A\r\nTEL:+1\r\nTEL:+3\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert!(report.conflicts.is_empty(), "{:?}", report.conflicts);
assert!(merged.contains("TEL:+2\r\n"), "got: {merged}");
assert!(merged.contains("TEL:+3\r\n"), "got: {merged}");
}
#[test]
fn an_edit_on_one_side_alone_does_not_contest_the_other_s_addition() {
let base = card("FN:A\r\nTEL:+1\r\n");
let left = card("FN:A\r\nTEL:+2\r\n");
let right = card("FN:A\r\nTEL:+1\r\nTEL:+3\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert!(report.conflicts.is_empty(), "{:?}", report.conflicts);
assert!(merged.contains("TEL:+3\r\n"), "got: {merged}");
}
#[test]
fn an_identical_addition_lands_once() {
let base = card("FN:X\r\n");
let side = card("FN:X\r\nEMAIL:x@y\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&side).unwrap();
let right = VcardCst::parse(&side).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert_eq!(merged.matches("EMAIL:x@y\r\n").count(), 1, "got: {merged}");
assert!(report.conflicts.is_empty());
}
#[test]
fn a_right_side_parameter_addition_lands_on_the_left_line() {
let base = card("FN:X\r\nTEL;TYPE=work:+1\r\n");
let left = card("FN:Y\r\nTEL;TYPE=work:+1\r\n");
let right = card("FN:X\r\nTEL;TYPE=work;PREF=1:+1\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert_eq!(
merged,
card("FN:Y\r\nTEL;TYPE=work;PREF=1:+1\r\n"),
"got: {merged}",
);
assert!(report.conflicts.is_empty());
assert!(matches!(
&report.right[0],
VcardMergeAction::ParamAdded { param, .. } if param.kind().is_some(),
));
}
#[test]
fn a_right_side_parameter_removal_takes_it_off_the_left_line() {
let base = card("FN:X\r\nTEL;TYPE=work;PREF=1:+1\r\n");
let left = card("FN:Y\r\nTEL;TYPE=work;PREF=1:+1\r\n");
let right = card("FN:X\r\nTEL;TYPE=work:+1\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert_eq!(
merged,
card("FN:Y\r\nTEL;TYPE=work:+1\r\n"),
"got: {merged}"
);
assert!(report.conflicts.is_empty());
assert!(matches!(
&report.right[0],
VcardMergeAction::ParamRemoved { .. },
));
}
#[test]
fn a_right_side_parameter_change_replaces_it_on_the_left_line() {
let base = card("FN:X\r\nTEL;PREF=1:+1\r\n");
let left = card("FN:Y\r\nTEL;PREF=1:+1\r\n");
let right = card("FN:X\r\nTEL;PREF=2:+1\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert_eq!(merged, card("FN:Y\r\nTEL;PREF=2:+1\r\n"), "got: {merged}");
assert!(report.conflicts.is_empty());
assert!(matches!(
&report.right[0],
VcardMergeAction::ParamChanged { .. },
));
}
#[test]
fn two_parameters_of_one_name_are_two_fields() {
let base = card("TEL;TYPE=work;TYPE=voice:+1\r\n");
let left = card("TEL;TYPE=home;TYPE=voice:+1\r\n");
let right = card("TEL;TYPE=work;TYPE=fax:+1\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert_eq!(
merged,
card("TEL;TYPE=home;TYPE=fax:+1\r\n"),
"got: {merged}",
);
assert!(report.conflicts.is_empty(), "{:?}", report.conflicts);
assert!(matches!(
&report.right[0],
VcardMergeAction::ParamChanged { index: 1, .. },
));
}
#[test]
fn divergent_parameter_changes_conflict_and_the_left_wins() {
let base = card("TEL;PREF=1:+1\r\n");
let left = card("TEL;PREF=2:+1\r\n");
let right = card("TEL;PREF=3:+1\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert_eq!(merged, card("TEL;PREF=2:+1\r\n"), "got: {merged}");
assert_eq!(report.conflicts.len(), 1);
}
#[test]
fn a_side_that_reordered_and_replaced_an_address_is_the_merged_card() {
let base = card("EMAIL;TYPE=work:ada@x.test\r\nEMAIL;TYPE=home:zoe@x.test\r\n");
let right = card("EMAIL;TYPE=home:zoe@x.test\r\nEMAIL;TYPE=work:bob@x.test\r\n");
let base = VcardCst::parse(&base).unwrap();
let held = base.to_string();
let left = VcardCst::parse(&held).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
assert_eq!(report.merged.to_bytes(), right.to_bytes());
assert!(report.conflicts.is_empty(), "{:?}", report.conflicts);
}
#[test]
fn an_edit_is_never_recorded_against_a_replacement() {
let base = card("EMAIL:ada@x.test\r\n");
let left = card("EMAIL:bob@x.test\r\n");
let right = card("EMAIL;TYPE=work:ada@x.test\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let merged = merge(&base, &left, &right).merged.to_string();
assert!(merged.contains("EMAIL:bob@x.test\r\n"), "got: {merged}");
assert!(
merged.contains("EMAIL;TYPE=work:ada@x.test\r\n"),
"got: {merged}",
);
}
#[test]
fn one_address_written_twice_tells_neither_instance_apart() {
let base = card("EMAIL:ada@x.test\r\nEMAIL:ada@x.test\r\n");
let right = card("EMAIL;TYPE=work:ada@x.test\r\nEMAIL:ada@x.test\r\n");
let base = VcardCst::parse(&base).unwrap();
let held = base.to_string();
let left = VcardCst::parse(&held).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert_eq!(merged.matches("EMAIL").count(), 2, "got: {merged}");
assert_eq!(merged.matches("TYPE=work").count(), 1, "got: {merged}");
assert!(report.conflicts.is_empty(), "{:?}", report.conflicts);
}
#[test]
fn an_identity_meets_the_other_case_it_was_written_in() {
let base = card("IMPP:XMPP:ada@x.test\r\n");
let left = card("IMPP;TYPE=work:XMPP:ada@x.test\r\n");
let right = card("IMPP;PREF=1:xmpp:ada@x.test\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert_eq!(
merged,
card("IMPP;TYPE=work;PREF=1:xmpp:ada@x.test\r\n"),
"got: {merged}",
);
assert!(report.conflicts.is_empty(), "{:?}", report.conflicts);
}
#[test]
fn a_pid_keeps_a_rename_a_rename() {
let base = card("EMAIL;PID=1:ada@x.test\r\n");
let left = card("EMAIL;PID=1;TYPE=work:ada@x.test\r\n");
let right = card("EMAIL;PID=1:bob@x.test\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
let merged = report.merged.to_string();
assert_eq!(
merged,
card("EMAIL;PID=1;TYPE=work:bob@x.test\r\n"),
"got: {merged}",
);
assert!(report.conflicts.is_empty(), "{:?}", report.conflicts);
}
#[test]
fn an_uncontested_field_keeps_the_change_its_one_side_made() {
let base = card("FN:A\r\nNOTE:x\r\n");
let left = card("FN:B\r\nNOTE:x\r\n");
let right = card("FN:A\r\nNOTE:x\r\n");
let base = VcardCst::parse(&base).unwrap();
let left = VcardCst::parse(&left).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &left, &right);
assert_eq!(report.merged.to_string(), card("FN:B\r\nNOTE:x\r\n"));
assert!(report.conflicts.is_empty(), "{:?}", report.conflicts);
}
#[test]
fn an_update_still_beats_a_removal() {
let base = card("FN:X\r\nNOTE:a\r\n");
let removed = card("FN:X\r\n");
let updated = card("FN:X\r\nNOTE:b\r\n");
let base = VcardCst::parse(&base).unwrap();
let removed = VcardCst::parse(&removed).unwrap();
let updated = VcardCst::parse(&updated).unwrap();
let report = merge(&base, &removed, &updated);
assert!(report.merged.to_string().contains("NOTE:b\r\n"));
let report = merge(&base, &updated, &removed);
assert!(report.merged.to_string().contains("NOTE:b\r\n"));
}
#[test]
fn a_list_value_with_a_semicolon_is_diffed_whole() {
let base = card("FN:A\r\nNICKNAME:a,b;x\r\n");
let right = card("FN:A\r\nNICKNAME:a,b,c;x\r\n");
let base = VcardCst::parse(&base).unwrap();
let right = VcardCst::parse(&right).unwrap();
let report = merge(&base, &base, &right);
let merged = report.merged.to_string();
assert!(merged.contains("NICKNAME:a,b,c;x\r\n"), "got: {merged}");
}
}