use enumset::{EnumSet, EnumSetType};
use std::fmt::{self, Display};
use thiserror::Error as ThisError;
#[derive(EnumSetType, Debug)]
pub enum MergeOptions {
BaseWins,
ErrorOnConflict,
DeepMergeObjectSchemas,
MergeInfo,
ReplaceListsWhenEmpty,
}
impl MergeOptions {
pub fn new() -> EnumSet<MergeOptions> {
EnumSet::empty()
}
pub fn only(&self) -> EnumSet<MergeOptions> {
EnumSet::only(*self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Resolution {
Incoming,
Base,
Errored,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ConflictKind {
ScalarOverridden,
RequiredScalarOverridden,
RefReplaced,
RefVsValue,
SchemaLeafReplaced,
ListReplaced,
ParameterVariantMismatch,
}
impl Display for ConflictKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
ConflictKind::ScalarOverridden => "scalar overridden",
ConflictKind::RequiredScalarOverridden => "required scalar overridden",
ConflictKind::RefReplaced => "ref replaced",
ConflictKind::RefVsValue => "ref/value mismatch",
ConflictKind::SchemaLeafReplaced => "schema leaf replaced",
ConflictKind::ListReplaced => "list replaced",
ConflictKind::ParameterVariantMismatch => "parameter variant mismatch",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MergeConflict {
pub path: String,
pub kind: ConflictKind,
pub resolution: Resolution,
}
impl Display for MergeConflict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {} ({:?})", self.path, self.kind, self.resolution)
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MergeReport {
pub conflicts: Vec<MergeConflict>,
}
impl MergeReport {
pub fn is_empty(&self) -> bool {
self.conflicts.is_empty()
}
pub fn len(&self) -> usize {
self.conflicts.len()
}
}
impl Display for MergeReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{} merge conflicts:", self.conflicts.len())?;
for c in &self.conflicts {
writeln!(f, "- {c}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, ThisError)]
#[error("merge aborted on conflict")]
pub struct MergeError {
pub conflicts: Vec<MergeConflict>,
}
pub trait Merge: Sized {
fn merge(
&mut self,
other: Self,
options: EnumSet<MergeOptions>,
) -> Result<MergeReport, MergeError>;
}
pub(crate) trait MergeWithContext {
fn merge_with_context(&mut self, other: Self, ctx: &mut MergeContext, path: &mut String);
}
pub(crate) struct MergeContext {
pub options: EnumSet<MergeOptions>,
pub conflicts: Vec<MergeConflict>,
pub errored: bool,
}
impl MergeContext {
pub fn is_option(&self, o: MergeOptions) -> bool {
self.options.contains(o)
}
pub fn record(&mut self, path: String, kind: ConflictKind, resolution: Resolution) {
self.conflicts.push(MergeConflict {
path,
kind,
resolution,
});
}
pub fn should_take_incoming(&mut self, path: &str, kind: ConflictKind) -> bool {
if self.is_option(MergeOptions::ErrorOnConflict) {
self.record(path.to_owned(), kind, Resolution::Errored);
self.errored = true;
false
} else if self.is_option(MergeOptions::BaseWins) {
self.record(path.to_owned(), kind, Resolution::Base);
false
} else {
self.record(path.to_owned(), kind, Resolution::Incoming);
true
}
}
pub(crate) fn new(options: EnumSet<MergeOptions>) -> MergeContext {
MergeContext {
options,
conflicts: Vec::new(),
errored: false,
}
}
}
impl From<MergeContext> for Result<MergeReport, MergeError> {
fn from(ctx: MergeContext) -> Self {
if ctx.errored {
Err(MergeError {
conflicts: ctx.conflicts,
})
} else {
Ok(MergeReport {
conflicts: ctx.conflicts,
})
}
}
}
impl fmt::Debug for MergeContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MergeContext")
.field("options", &self.options)
.field("conflicts", &self.conflicts)
.field("errored", &self.errored)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_take_incoming_default_takes_incoming_and_records() {
let mut ctx: MergeContext = MergeContext::new(MergeOptions::new());
let took = ctx.should_take_incoming("#.x", ConflictKind::ScalarOverridden);
assert!(took);
assert!(!ctx.errored);
assert_eq!(ctx.conflicts.len(), 1);
assert_eq!(ctx.conflicts[0].resolution, Resolution::Incoming);
}
#[test]
fn should_take_incoming_base_wins_keeps_base() {
let mut ctx: MergeContext = MergeContext::new(MergeOptions::BaseWins.only());
let took = ctx.should_take_incoming("#.x", ConflictKind::ScalarOverridden);
assert!(!took);
assert!(!ctx.errored);
assert_eq!(ctx.conflicts[0].resolution, Resolution::Base);
}
#[test]
fn should_take_incoming_error_mode_marks_errored() {
let mut ctx: MergeContext = MergeContext::new(MergeOptions::ErrorOnConflict.only());
let took = ctx.should_take_incoming("#.x", ConflictKind::ScalarOverridden);
assert!(!took);
assert!(ctx.errored);
assert_eq!(ctx.conflicts[0].resolution, Resolution::Errored);
}
#[test]
fn context_into_result_returns_err_when_errored() {
let mut ctx: MergeContext = MergeContext::new(MergeOptions::ErrorOnConflict.only());
ctx.should_take_incoming("#.x", ConflictKind::ScalarOverridden);
let res: Result<MergeReport, MergeError> = ctx.into();
assert!(res.is_err());
}
#[test]
fn context_into_result_returns_ok_with_report() {
let mut ctx: MergeContext = MergeContext::new(MergeOptions::new());
ctx.should_take_incoming("#.x", ConflictKind::ScalarOverridden);
let res: Result<MergeReport, MergeError> = ctx.into();
let report = res.expect("ok");
assert_eq!(report.len(), 1);
}
#[test]
fn merge_options_only_helper_returns_single_set() {
let only = MergeOptions::BaseWins.only();
assert!(only.contains(MergeOptions::BaseWins));
assert!(!only.contains(MergeOptions::ErrorOnConflict));
assert_eq!(only.len(), 1);
}
#[test]
fn merge_options_new_is_empty() {
assert!(MergeOptions::new().is_empty());
}
#[test]
fn merge_report_default_is_empty_and_len_zero() {
let r = MergeReport::default();
assert!(r.is_empty());
assert_eq!(r.len(), 0);
}
#[test]
fn merge_report_display_renders_count_and_bullets() {
let r = MergeReport {
conflicts: vec![
MergeConflict {
path: "#.a".into(),
kind: ConflictKind::ScalarOverridden,
resolution: Resolution::Incoming,
},
MergeConflict {
path: "#.b".into(),
kind: ConflictKind::RefReplaced,
resolution: Resolution::Base,
},
],
};
let s = r.to_string();
assert!(s.starts_with("2 merge conflicts:"));
assert!(s.contains("- #.a: scalar overridden (Incoming)"));
assert!(s.contains("- #.b: ref replaced (Base)"));
}
#[test]
fn merge_report_display_empty_still_renders_header() {
let s = MergeReport::default().to_string();
assert!(s.starts_with("0 merge conflicts:"));
}
#[test]
fn merge_conflict_display_renders_path_kind_resolution() {
let c = MergeConflict {
path: "#.foo".into(),
kind: ConflictKind::ListReplaced,
resolution: Resolution::Errored,
};
assert_eq!(c.to_string(), "#.foo: list replaced (Errored)");
}
#[test]
fn conflict_kind_display_covers_every_variant() {
for (k, expected) in [
(ConflictKind::ScalarOverridden, "scalar overridden"),
(
ConflictKind::RequiredScalarOverridden,
"required scalar overridden",
),
(ConflictKind::RefReplaced, "ref replaced"),
(ConflictKind::RefVsValue, "ref/value mismatch"),
(ConflictKind::SchemaLeafReplaced, "schema leaf replaced"),
(ConflictKind::ListReplaced, "list replaced"),
(
ConflictKind::ParameterVariantMismatch,
"parameter variant mismatch",
),
] {
assert_eq!(k.to_string(), expected);
}
}
#[test]
fn merge_error_display_shows_message() {
let err = MergeError {
conflicts: vec![MergeConflict {
path: "#".into(),
kind: ConflictKind::ScalarOverridden,
resolution: Resolution::Errored,
}],
};
assert_eq!(err.to_string(), "merge aborted on conflict");
}
#[test]
fn merge_context_debug_includes_state() {
let ctx: MergeContext = MergeContext::new(MergeOptions::new());
let s = format!("{ctx:?}");
assert!(s.contains("MergeContext"));
assert!(s.contains("conflicts: []"));
assert!(s.contains("errored: false"));
}
#[test]
fn merge_context_visit_inserts_path() {
let ctx: MergeContext = MergeContext::new(MergeOptions::new());
assert!(!format!("{ctx:?}").contains("visited"));
}
}