using System.Runtime.InteropServices;
namespace DodoSSH.Client.Domain;
/// Which of the two diverging replicas a value came from.
public enum MergeSide
{
/// The edit made on this machine.
Local = 0,
/// The edit that arrived from the server.
Remote = 1,
}
/// How a single field was resolved.
public enum MergeDecision
{
///
/// Both sides hold the same value — either neither changed it, or both made the identical
/// change. Distinguishing those two is not useful: the outcome is the same and no one is
/// surprised.
///
Agreed = 0,
/// Only this machine changed it.
TookLocal = 1,
/// Only the server side changed it.
TookRemote = 2,
/// Both changed it, differently. One value survives and the other is reported.
Conflicted = 3,
}
/// The outcome of merging one field.
/// The field's type.
/// The value to keep.
/// How it was resolved.
///
/// The value that lost, meaningful only when is
/// . Never simply dropped: the caller is expected to record it.
///
[StructLayout(LayoutKind.Auto)]
public readonly record struct FieldMerge(T Value, MergeDecision Decision, T? Discarded)
{
/// Whether both sides changed this field to different values.
public bool IsConflicted => Decision == MergeDecision.Conflicted;
}
/// A key whose value both sides changed, or which one side removed while the other edited.
/// Key type.
/// Value type.
/// The key in question.
/// The value that survives, or if the key is removed.
/// Which replica's intent was overridden.
///
/// The value that lost, or when what lost was a removal.
///
///
/// True when the overridden intent was to remove the key rather than to set it to a different value.
///
[StructLayout(LayoutKind.Auto)]
public readonly record struct MapConflict(
TKey Key,
TValue? Kept,
MergeSide DiscardedSide,
TValue? Discarded,
bool DiscardedWasRemoval);
/// The outcome of merging a keyed collection.
/// Key type.
/// Value type.
/// The resulting collection.
/// Every key where the two sides disagreed.
[StructLayout(LayoutKind.Auto)]
public readonly record struct MapMerge(
IReadOnlyDictionary Merged,
IReadOnlyList> Conflicts)
where TKey : notnull;
///
/// The merge primitives: resolve a field, or a keyed collection, from a common ancestor and two
/// divergent versions.
///
///
///
/// The server cannot do any of this — it cannot read a payload, so it cannot merge one. That is why
/// a conflicting push comes back with the server's current row rather than being resolved for us,
/// and why this code is the last line of defence against losing a credential.
///
///
/// Why the remote side wins a genuine clash. It has to be one of them, and it has to be the
/// same one on every replica. If each client kept its own value, two clients would resolve the same
/// triple in opposite directions, each push would conflict with the other's, and they would ping-pong
/// forever without converging. Deferring to the value already on the server converges in one round.
///
///
/// The losing value is never discarded silently. Every primitive returns it, the item-level
/// merge collects them, and the sync engine writes them to a conflict log the user can act on. This
/// is the whole point: a merge that quietly drops the password someone just typed is worse than one
/// that refuses to merge at all.
///
///
public static class ThreeWayMerge
{
///
/// Resolves one field.
///
/// The value both sides started from.
/// This machine's value.
/// The server's value.
/// Value comparison; defaults to .
public static FieldMerge Scalar(
T ancestor,
T local,
T remote,
IEqualityComparer? comparer = null)
{
comparer ??= EqualityComparer.Default;
// Checked first, so two people making the identical edit is agreement rather than a
// conflict they have to be bothered about.
if (comparer.Equals(local, remote))
{
return new FieldMerge(local, MergeDecision.Agreed, default);
}
if (comparer.Equals(local, ancestor))
{
return new FieldMerge(remote, MergeDecision.TookRemote, default);
}
if (comparer.Equals(remote, ancestor))
{
return new FieldMerge(local, MergeDecision.TookLocal, default);
}
return new FieldMerge(remote, MergeDecision.Conflicted, local);
}
///
/// Resolves a keyed collection key by key.
///
///
///
/// Per-key rather than whole-collection, which is the difference between two people each adding
/// a directive and both keeping it, versus one of them losing theirs to a conflict. That is the
/// single most visible benefit of a field-level merge over last-writer-wins.
///
///
/// An edit beats a removal. Where one side deleted a key and the other changed its value,
/// the value survives and the removal is reported. The asymmetry is deliberate and it is not a
/// preference: re-applying a removal costs one click, while a discarded value may be the only
/// copy of something the user cannot reconstruct.
///
///
/// The state both sides started from.
/// This machine's state.
/// The server's state.
/// Defines key identity.
/// Value comparison; defaults to .
public static MapMerge Map(
IReadOnlyDictionary ancestor,
IReadOnlyDictionary local,
IReadOnlyDictionary remote,
IEqualityComparer keyComparer,
IEqualityComparer? valueComparer = null)
where TKey : notnull
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
ArgumentNullException.ThrowIfNull(keyComparer);
valueComparer ??= EqualityComparer.Default;
var merged = new Dictionary(keyComparer);
var conflicts = new List>();
foreach (var key in UnionOfKeys(ancestor, local, remote, keyComparer))
{
var a = Slot.For(ancestor, key);
var l = Slot.For(local, key);
var r = Slot.For(remote, key);
var resolved = ResolveKey(key, a, l, r, valueComparer, conflicts);
if (resolved.Present)
{
merged[key] = resolved.Value!;
}
}
return new MapMerge(merged, conflicts);
}
/// One key's state on one replica: present with a value, or absent.
[StructLayout(LayoutKind.Auto)]
private readonly record struct Slot(bool Present, TValue? Value)
{
internal bool Matches(in Slot other, IEqualityComparer comparer) =>
Present == other.Present
&& (!Present || comparer.Equals(Value!, other.Value!));
}
private static class Slot
{
internal static Slot For(
IReadOnlyDictionary source,
TKey key) =>
source.TryGetValue(key, out var value)
? new Slot(true, value)
: new Slot(false, default);
}
private static Slot ResolveKey(
TKey key,
in Slot ancestor,
in Slot local,
in Slot remote,
IEqualityComparer valueComparer,
List> conflicts)
{
if (local.Matches(remote, valueComparer))
{
return local;
}
if (local.Matches(ancestor, valueComparer))
{
return remote;
}
if (remote.Matches(ancestor, valueComparer))
{
return local;
}
// Both sides moved. Prefer whichever still holds a value, so an edit outlives a removal;
// where both hold one, defer to the server so every replica converges the same way.
var winner = remote.Present ? remote : local;
var loserSide = remote.Present ? MergeSide.Local : MergeSide.Remote;
var loser = remote.Present ? local : remote;
conflicts.Add(new MapConflict(
key,
winner.Value,
loserSide,
loser.Present ? loser.Value : default,
DiscardedWasRemoval: !loser.Present));
return winner;
}
private static IEnumerable UnionOfKeys(
IReadOnlyDictionary ancestor,
IReadOnlyDictionary local,
IReadOnlyDictionary remote,
IEqualityComparer keyComparer)
where TKey : notnull
{
var seen = new HashSet(keyComparer);
foreach (var key in ancestor.Keys.Concat(local.Keys).Concat(remote.Keys))
{
if (seen.Add(key))
{
yield return key;
}
}
}
}