Skip to main content

gonzalo_core/
merge.rs

1//! Merge strategies keyed by `MergeClass`. Used by `Sync` (M2) and by
2//! callers resolving a `PutResult::Conflict`.
3
4use crate::record::{Body, MergeClass};
5
6/// The result of attempting an automatic merge of two divergent bodies.
7#[derive(Clone, Debug, PartialEq, Eq)]
8#[must_use = "a MergeOutcome may need caller resolution and must not be ignored"]
9pub enum MergeOutcome {
10    /// A merged body was produced automatically.
11    Merged(Body),
12    /// No safe automatic merge; the caller must resolve.
13    NeedsResolution,
14}
15
16/// Attempt to merge `ours` and `theirs` given their common `base`,
17/// according to `class`.
18///
19/// - `AppendOnly`: `ours` verbatim, followed by the tail of `theirs` past the
20///   shared line-prefix of the two sides. A strict append-only union that
21///   preserves committed content byte-exact — it never drops blank lines and
22///   never dedups repeated lines (doing so is silent data loss, ADR 0005).
23///   Suits append-only topics and session transcripts.
24/// - `Structured`: field-level 3-way merge of JSON object bodies (see
25///   [`structured_merge`]). Disjoint field edits auto-merge; the same field
26///   changed differently on both sides is a genuine conflict.
27/// - `Opaque`: always `NeedsResolution`.
28/// - `Derived`: never `NeedsResolution`. The body is regenerable and views are
29///   single-writer (ADR 0012), so a divergence is a rare race. `merge` receives
30///   only bodies (no `Meta`), so it cannot compare `updated` timestamps; it
31///   resolves the race deterministically in favor of side A (`ours`) — the
32///   discarded side can be re-derived from source.
33pub fn merge(class: MergeClass, base: &Body, ours: &Body, theirs: &Body) -> MergeOutcome {
34    match class {
35        MergeClass::AppendOnly => append_only_merge(base, ours, theirs),
36        MergeClass::Structured => structured_merge(base, ours, theirs),
37        MergeClass::Opaque => MergeOutcome::NeedsResolution,
38        MergeClass::Derived => MergeOutcome::Merged(ours.clone()),
39    }
40}
41
42/// Field-level 3-way merge of JSON bodies.
43///
44/// Each body is parsed as JSON. For every key, the standard 3-way rule applies:
45/// if only one side changed it from `base`, take that side; if both changed it
46/// the same way, take it; if both changed it differently, recurse when both are
47/// objects, otherwise surface a conflict. Non-object roots, or bodies that
48/// aren't valid JSON, fall back to `NeedsResolution` (the safe default — a
49/// caller resolves rather than risk a wrong merge).
50fn structured_merge(base: &Body, ours: &Body, theirs: &Body) -> MergeOutcome {
51    let (Ok(base), Ok(ours), Ok(theirs)) = (
52        serde_json::from_slice::<serde_json::Value>(base.bytes()),
53        serde_json::from_slice::<serde_json::Value>(ours.bytes()),
54        serde_json::from_slice::<serde_json::Value>(theirs.bytes()),
55    ) else {
56        return MergeOutcome::NeedsResolution;
57    };
58    match merge_value(&base, &ours, &theirs) {
59        Some(merged) => match serde_json::to_vec(&merged) {
60            Ok(bytes) => MergeOutcome::Merged(Body::Inline(bytes)),
61            Err(_) => MergeOutcome::NeedsResolution,
62        },
63        None => MergeOutcome::NeedsResolution,
64    }
65}
66
67/// 3-way merge of a single JSON value. Returns `None` on a genuine conflict.
68///
69/// Scalars and arrays are merged atomically (by equality); objects are merged
70/// key-by-key via [`merge_field`], which models presence so a key deleted on one
71/// side is honored rather than turned into `null`.
72fn merge_value(
73    base: &serde_json::Value,
74    ours: &serde_json::Value,
75    theirs: &serde_json::Value,
76) -> Option<serde_json::Value> {
77    // Both sides agree (covers "neither changed" and "both changed identically").
78    if ours == theirs {
79        return Some(ours.clone());
80    }
81    // Only one side diverged from base — take the side that changed.
82    if ours == base {
83        return Some(theirs.clone());
84    }
85    if theirs == base {
86        return Some(ours.clone());
87    }
88    // Both changed differently. Recurse only when all three are objects;
89    // anything else (scalars, arrays, type changes) is a genuine conflict.
90    match (base.as_object(), ours.as_object(), theirs.as_object()) {
91        (Some(base_obj), Some(ours_obj), Some(theirs_obj)) => {
92            let mut out = serde_json::Map::new();
93            let mut keys: Vec<&String> = base_obj
94                .keys()
95                .chain(ours_obj.keys())
96                .chain(theirs_obj.keys())
97                .collect();
98            keys.sort();
99            keys.dedup();
100            for key in keys {
101                // `?` propagates a conflict; `Some(v)` keeps the key, `None`
102                // drops it (deleted on the winning side).
103                if let Some(v) =
104                    merge_field(base_obj.get(key), ours_obj.get(key), theirs_obj.get(key))?
105                {
106                    out.insert(key.clone(), v);
107                }
108            }
109            Some(serde_json::Value::Object(out))
110        }
111        _ => None,
112    }
113}
114
115/// 3-way merge of one object field, modeling presence as `Option` (inner
116/// `None` = the key is absent/deleted). The outer `Option` is the merge result:
117/// `None` = genuine conflict; `Some(Some(v))` = keep `v`; `Some(None)` = drop
118/// the key.
119fn merge_field(
120    base: Option<&serde_json::Value>,
121    ours: Option<&serde_json::Value>,
122    theirs: Option<&serde_json::Value>,
123) -> Option<Option<serde_json::Value>> {
124    // Both sides agree on presence + value.
125    if ours == theirs {
126        return Some(ours.cloned());
127    }
128    // Only one side changed relative to base — take the changed side.
129    if ours == base {
130        return Some(theirs.cloned());
131    }
132    if theirs == base {
133        return Some(ours.cloned());
134    }
135    // Both changed differently: recurse if both are still present objects,
136    // else it's a genuine conflict (incl. modify/delete).
137    match (ours, theirs) {
138        (Some(o), Some(t)) => {
139            let b = base.unwrap_or(&serde_json::Value::Null);
140            merge_value(b, o, t).map(Some)
141        }
142        _ => None,
143    }
144}
145
146/// Append-only 3-way merge that never loses committed content.
147///
148/// By the append-only invariant, `base` is a line-prefix of both sides, so the
149/// shared committed content is exactly the longest common line-prefix of `ours`
150/// and `theirs` (computing it from the two sides rather than `base` also makes
151/// the merge correct under the empty base that [`crate::sync`] passes). The
152/// result is `ours` byte-for-byte, followed by the lines of `theirs` beyond that
153/// shared prefix — its divergent appended tail.
154///
155/// Crucially this does NOT split-and-filter or dedup: blank lines and legitimate
156/// repeated lines inside committed content survive verbatim on both sides. `base`
157/// is unused because emitting `ours` verbatim already preserves it.
158fn append_only_merge(_base: &Body, ours: &Body, theirs: &Body) -> MergeOutcome {
159    let ours_lines = split_lines(ours.bytes());
160    let theirs_lines = split_lines(theirs.bytes());
161    // Compare by line *content* (ignoring a trailing `\n`) so a non-newline-
162    // terminated final line still counts as shared with a newline-terminated
163    // peer — otherwise `"a\nb"` vs `"a\nb\nc\n"` would treat `b` as divergent
164    // and fuse it with `theirs`' `b` into `bb`.
165    let shared = ours_lines
166        .iter()
167        .zip(theirs_lines.iter())
168        .take_while(|(o, t)| strip_nl(o) == strip_nl(t))
169        .count();
170
171    let mut out: Vec<u8> = ours.bytes().to_vec();
172    let tail = &theirs_lines[shared..];
173    // If `ours` didn't end in a newline, separate its final line from `theirs`'
174    // appended tail so the two distinct lines don't fuse into one.
175    if !tail.is_empty() && out.last().is_some_and(|&b| b != b'\n') {
176        out.push(b'\n');
177    }
178    for line in tail {
179        out.extend_from_slice(line);
180    }
181    MergeOutcome::Merged(Body::Inline(out))
182}
183
184/// A line slice without its trailing `\n`, for newline-insensitive comparison.
185fn strip_nl(line: &[u8]) -> &[u8] {
186    line.strip_suffix(b"\n").unwrap_or(line)
187}
188
189/// Split `bytes` into lines, each slice keeping its trailing `\n` (the final
190/// line has none iff `bytes` doesn't end in `\n`). Concatenating the result
191/// reproduces `bytes` exactly, so blank and repeated lines round-trip verbatim.
192fn split_lines(bytes: &[u8]) -> Vec<&[u8]> {
193    let mut lines = Vec::new();
194    let mut start = 0;
195    for (i, &b) in bytes.iter().enumerate() {
196        if b == b'\n' {
197            lines.push(&bytes[start..=i]);
198            start = i + 1;
199        }
200    }
201    if start < bytes.len() {
202        lines.push(&bytes[start..]);
203    }
204    lines
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn body(s: &str) -> Body {
212        Body::Inline(s.as_bytes().to_vec())
213    }
214
215    #[test]
216    fn append_only_unions_disjoint_additions() {
217        let base = body("a\n");
218        let ours = body("a\nb\n");
219        let theirs = body("a\nc\n");
220        let MergeOutcome::Merged(m) = merge(MergeClass::AppendOnly, &base, &ours, &theirs) else {
221            panic!("expected merge");
222        };
223        assert_eq!(m, body("a\nb\nc\n"));
224    }
225
226    #[test]
227    fn append_only_identical_sides_yield_ours() {
228        // When both sides hold the same content, the divergent tail of `theirs`
229        // is empty, so the merge is exactly `ours` — no duplication.
230        let base = body("a\n");
231        let ours = body("a\nb\n");
232        let theirs = body("a\nb\n");
233        let MergeOutcome::Merged(m) = merge(MergeClass::AppendOnly, &base, &ours, &theirs) else {
234            panic!("expected merge");
235        };
236        assert_eq!(m, body("a\nb\n"));
237    }
238
239    #[test]
240    fn append_only_appends_theirs_tail_and_preserves_repeats() {
241        // Corrected semantics (was `append_only_dedups_new_lines_...`): the merge
242        // is `ours` verbatim followed by the tail of `theirs` past the shared
243        // line-prefix. Repeated lines that are genuinely part of committed
244        // content are NOT deduped — losing them would be silent data loss.
245        // Shared prefix here is "a\nb\n"; ours keeps its "c\nc\n" repeat and
246        // theirs contributes only its divergent tail "e\nf\n".
247        let base = body("a\nb\n");
248        let ours = body("a\nb\nc\nc\nd\n");
249        let theirs = body("a\nb\ne\nf\n");
250        let MergeOutcome::Merged(m) = merge(MergeClass::AppendOnly, &base, &ours, &theirs) else {
251            panic!("expected merge");
252        };
253        assert_eq!(m, body("a\nb\nc\nc\nd\ne\nf\n"));
254    }
255
256    #[test]
257    fn append_only_preserves_blank_and_duplicate_lines() {
258        // Regression for #133: with an empty base (what `sync` passes), a body
259        // whose committed content legitimately contains a blank line and a
260        // repeated line must round-trip WITHOUT losing the blank line or
261        // collapsing the repeat — on both the shared prefix and the appended
262        // tails. Shared prefix: "a\n\nyes\nyes\n"; ours appends "from_a\n",
263        // theirs appends "from_b\n".
264        let base = body("");
265        let ours = body("a\n\nyes\nyes\nfrom_a\n");
266        let theirs = body("a\n\nyes\nyes\nfrom_b\n");
267        let MergeOutcome::Merged(m) = merge(MergeClass::AppendOnly, &base, &ours, &theirs) else {
268            panic!("expected merge");
269        };
270        assert_eq!(m, body("a\n\nyes\nyes\nfrom_a\nfrom_b\n"));
271    }
272
273    #[test]
274    fn append_only_handles_non_newline_terminated_ours() {
275        // `ours` has no trailing newline on its final line `b`. The last line
276        // must be recognized as shared with `theirs`' `b\n` (not fused into
277        // `bb`), and `theirs`' divergent tail appended cleanly on its own line.
278        let base = body("");
279        let ours = body("a\nb");
280        let theirs = body("a\nb\nc\n");
281        let MergeOutcome::Merged(m) = merge(MergeClass::AppendOnly, &base, &ours, &theirs) else {
282            panic!("expected merge");
283        };
284        assert_eq!(m, body("a\nb\nc\n"));
285
286        // And when the final line genuinely diverges, it is preserved (not
287        // fused) with a separating newline before theirs' tail.
288        let ours2 = body("a\nx");
289        let theirs2 = body("a\ny\nz\n");
290        let MergeOutcome::Merged(m2) = merge(MergeClass::AppendOnly, &base, &ours2, &theirs2)
291        else {
292            panic!("expected merge");
293        };
294        assert_eq!(m2, body("a\nx\ny\nz\n"));
295    }
296
297    #[test]
298    fn opaque_needs_resolution() {
299        let b = body("x\n");
300        assert_eq!(
301            merge(MergeClass::Opaque, &b, &b, &b),
302            MergeOutcome::NeedsResolution
303        );
304    }
305
306    #[test]
307    fn derived_takes_side_a_deterministically() {
308        // Derived bodies are regenerable (e.g. per-view code-graph manifests),
309        // so reconciliation never surfaces a conflict. `merge` has no access to
310        // `Meta`, so it cannot honor last-writer-wins by timestamp; it resolves
311        // deterministically in favor of side A (`ours`). The choice is purely
312        // positional — swapping the arguments picks the other side — and the
313        // discarded side can be re-derived from source.
314        let base = body("base");
315        let ours = body("ours-version");
316        let theirs = body("theirs-version");
317        let MergeOutcome::Merged(m) = merge(MergeClass::Derived, &base, &ours, &theirs) else {
318            panic!("Derived must merge, never NeedsResolution");
319        };
320        assert_eq!(m, ours);
321        // Purely positional: whichever body is side A wins.
322        let MergeOutcome::Merged(swapped) = merge(MergeClass::Derived, &base, &theirs, &ours)
323        else {
324            panic!("Derived must merge, never NeedsResolution");
325        };
326        assert_eq!(swapped, theirs);
327    }
328
329    /// Run a structured merge over JSON string inputs and return the parsed
330    /// merged value, panicking if the merge needed resolution.
331    fn structured(base: &str, ours: &str, theirs: &str) -> serde_json::Value {
332        match merge(
333            MergeClass::Structured,
334            &body(base),
335            &body(ours),
336            &body(theirs),
337        ) {
338            MergeOutcome::Merged(b) => serde_json::from_slice(b.bytes()).unwrap(),
339            MergeOutcome::NeedsResolution => panic!("expected a merge, got NeedsResolution"),
340        }
341    }
342
343    fn structured_conflicts(base: &str, ours: &str, theirs: &str) -> bool {
344        matches!(
345            merge(
346                MergeClass::Structured,
347                &body(base),
348                &body(ours),
349                &body(theirs),
350            ),
351            MergeOutcome::NeedsResolution
352        )
353    }
354
355    #[test]
356    fn structured_merges_disjoint_field_edits() {
357        // ours changes `name`, theirs changes `content` — both survive.
358        let merged = structured(
359            r#"{"name":"a","content":"x"}"#,
360            r#"{"name":"b","content":"x"}"#,
361            r#"{"name":"a","content":"y"}"#,
362        );
363        assert_eq!(merged, serde_json::json!({"name":"b","content":"y"}));
364    }
365
366    #[test]
367    fn structured_takes_the_only_changed_side() {
368        // Only theirs changed a field; ours is identical to base.
369        let merged = structured(r#"{"k":1,"j":2}"#, r#"{"k":1,"j":2}"#, r#"{"k":9,"j":2}"#);
370        assert_eq!(merged, serde_json::json!({"k":9,"j":2}));
371    }
372
373    #[test]
374    fn structured_adds_new_keys_from_both_sides() {
375        let merged = structured(r#"{"a":1}"#, r#"{"a":1,"b":2}"#, r#"{"a":1,"c":3}"#);
376        assert_eq!(merged, serde_json::json!({"a":1,"b":2,"c":3}));
377    }
378
379    #[test]
380    fn structured_conflicts_on_same_field_changed_differently() {
381        assert!(structured_conflicts(
382            r#"{"k":1}"#,
383            r#"{"k":2}"#,
384            r#"{"k":3}"#,
385        ));
386    }
387
388    #[test]
389    fn structured_merges_nested_objects() {
390        // Disjoint edits within a nested object merge field-by-field.
391        let merged = structured(
392            r#"{"meta":{"a":1,"b":2}}"#,
393            r#"{"meta":{"a":9,"b":2}}"#,
394            r#"{"meta":{"a":1,"b":8}}"#,
395        );
396        assert_eq!(merged, serde_json::json!({"meta":{"a":9,"b":8}}));
397    }
398
399    #[test]
400    fn structured_conflicts_on_nested_same_field() {
401        assert!(structured_conflicts(
402            r#"{"meta":{"a":1}}"#,
403            r#"{"meta":{"a":2}}"#,
404            r#"{"meta":{"a":3}}"#,
405        ));
406    }
407
408    #[test]
409    fn structured_honors_one_sided_deletion() {
410        // ours deletes `b`, theirs leaves it untouched → deleted.
411        let merged = structured(r#"{"a":1,"b":2}"#, r#"{"a":1}"#, r#"{"a":1,"b":2}"#);
412        assert_eq!(merged, serde_json::json!({"a":1}));
413    }
414
415    #[test]
416    fn structured_conflicts_on_modify_delete() {
417        // ours deletes `b`, theirs modifies it → genuine conflict.
418        assert!(structured_conflicts(
419            r#"{"a":1,"b":2}"#,
420            r#"{"a":1}"#,
421            r#"{"a":1,"b":9}"#,
422        ));
423    }
424
425    #[test]
426    fn structured_arrays_merge_atomically() {
427        // Identical array edit on both sides → fine.
428        let merged = structured(r#"{"xs":[1]}"#, r#"{"xs":[1,2]}"#, r#"{"xs":[1,2]}"#);
429        assert_eq!(merged, serde_json::json!({"xs":[1,2]}));
430        // Divergent array edits → conflict (atomic).
431        assert!(structured_conflicts(
432            r#"{"xs":[1]}"#,
433            r#"{"xs":[1,2]}"#,
434            r#"{"xs":[1,3]}"#,
435        ));
436    }
437
438    #[test]
439    fn structured_non_json_falls_back_to_needs_resolution() {
440        assert!(structured_conflicts("not json", "also not", "nope"));
441    }
442
443    #[test]
444    fn structured_non_object_root_conflict_needs_resolution() {
445        // Scalar roots changed differently → NeedsResolution (no field structure).
446        assert!(structured_conflicts("1", "2", "3"));
447    }
448}