Skip to main content

lore/sync/
merge.rs

1//! Combining two copies of a library that changed apart.
2//!
3//! Git moves the file between machines, but it merges by line, and two
4//! machines that each saved a command have both added lines at the end of the
5//! same list. Git calls that a conflict although nothing clashes. The merge
6//! here works on entries by id instead, so the only real conflict is one entry
7//! changed differently in two places.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use anyhow::Result;
12
13use crate::model::Entry;
14use crate::store::definitions::{self, NewEntry};
15
16/// The outcome of combining this machine's library with the synced one.
17#[derive(Debug)]
18pub struct Merged {
19    /// The library as it should now be, on this machine and in the repository.
20    pub text: String,
21    /// Changes taken from other machines.
22    pub received: usize,
23    /// Changes this machine had made since it last synced.
24    pub sent: usize,
25    /// Entries that were changed on both sides. The first id holds the
26    /// version that reached the repository first, and the second holds this
27    /// machine's version, kept under a new id rather than lost.
28    pub kept_both: Vec<(String, String)>,
29}
30
31/// One copy of a library, reduced to what the merge compares.
32struct Side {
33    entries: BTreeMap<String, Entry>,
34    /// Ids in the order the file declares them, so additions arrive in the
35    /// order they were made.
36    order: Vec<String>,
37    disabled: BTreeSet<String>,
38}
39
40impl Side {
41    fn parse(text: &str, origin: &str) -> Result<Self> {
42        let library = definitions::parse_user(text, origin)?;
43        let order = library.commands.iter().map(|e| e.id.clone()).collect();
44        let entries = library
45            .commands
46            .into_iter()
47            .map(|entry| (entry.id.clone(), entry))
48            .collect();
49
50        Ok(Self {
51            entries,
52            order,
53            disabled: library.disabled.into_iter().collect(),
54        })
55    }
56}
57
58/// Merges `ours` and `theirs`, both descended from `base`.
59///
60/// `base` is the library as it was the last time this machine synced, and
61/// `None` when it never has. `theirs` is `None` when the repository holds no
62/// library yet.
63///
64/// When only one side changed, its text is taken whole, so nothing about how
65/// either file is written changes for no reason. Only when both changed is the
66/// other side's work spliced into this machine's text, entry by entry, which
67/// leaves this machine's comments and ordering where they were.
68///
69/// When both sides changed the same entry, the repository's version keeps the
70/// id and this machine's is added under a new one. Deciding it that way on
71/// every machine means they agree after one round instead of passing their
72/// differences back and forth.
73pub fn merge(base: Option<&str>, ours: &str, theirs: Option<&str>) -> Result<Merged> {
74    let base = Side::parse(base.unwrap_or_default(), "the library as last synced")?;
75    let local = Side::parse(ours, "this machine's library")?;
76    let sent = changes(&base, &local);
77
78    let unchanged = |text: &str, received| Merged {
79        text: text.to_string(),
80        received,
81        sent,
82        kept_both: Vec::new(),
83    };
84
85    let Some(theirs) = theirs else {
86        return Ok(unchanged(ours, 0));
87    };
88    let remote = Side::parse(theirs, "the synced library")?;
89    let incoming = changes(&base, &remote);
90
91    if incoming == 0 {
92        return Ok(unchanged(ours, 0));
93    }
94    if sent == 0 {
95        return Ok(Merged {
96            text: theirs.to_string(),
97            received: incoming,
98            sent: 0,
99            kept_both: Vec::new(),
100        });
101    }
102
103    let mut text = ours.to_string();
104    let mut received = 0;
105    let mut kept_both = Vec::new();
106    let mut taken: BTreeSet<String> = local
107        .entries
108        .keys()
109        .chain(remote.entries.keys())
110        .cloned()
111        .collect();
112
113    let mut ids = remote.order.clone();
114    for id in local.order.iter().chain(&base.order) {
115        if !ids.contains(id) {
116            ids.push(id.clone());
117        }
118    }
119
120    for id in ids {
121        let was = base.entries.get(&id);
122        let mine = local.entries.get(&id);
123        let other = remote.entries.get(&id);
124
125        // Nothing to take: the two agree, or only this side changed.
126        if mine == other || other == was {
127            continue;
128        }
129
130        let untouched_here = mine == was;
131        received += 1;
132        match (mine, other) {
133            // New or changed there, and either untouched here or removed here:
134            // an edit is kept rather than lost to a removal.
135            (None, Some(other)) => {
136                text = definitions::upserted(&text, &NewEntry::from(other))?.0;
137            }
138            (Some(_), Some(other)) if untouched_here => {
139                text = definitions::upserted(&text, &NewEntry::from(other))?.0;
140            }
141            (Some(mine), Some(other)) => {
142                let copy = unused(&id, &taken);
143                taken.insert(copy.clone());
144
145                text = definitions::upserted(&text, &NewEntry::from(other))?.0;
146                let mut kept = NewEntry::from(mine);
147                kept.id = copy.clone();
148                text = definitions::appended(&text, &kept)?;
149
150                kept_both.push((id, copy));
151            }
152            // Removed there and untouched here.
153            (Some(_), None) if untouched_here => {
154                text = definitions::removed(&text, &id).unwrap_or(text);
155            }
156            // Changed here and removed there: this machine's version stands
157            // and goes back up with the next push.
158            (Some(_), None) | (None, None) => received -= 1,
159        }
160    }
161
162    let patterns: BTreeSet<&String> = base
163        .disabled
164        .iter()
165        .chain(&local.disabled)
166        .chain(&remote.disabled)
167        .collect();
168
169    for pattern in patterns {
170        let was = base.disabled.contains(pattern);
171        let mine = local.disabled.contains(pattern);
172        let other = remote.disabled.contains(pattern);
173
174        // Either the two agree or only this side changed. With a yes or no
175        // there is no third way for both to have changed.
176        if mine == other || other == was {
177            continue;
178        }
179
180        text = if other {
181            definitions::with_disabled(&text, pattern)?
182        } else {
183            definitions::with_enabled(&text, pattern)
184        };
185        received += 1;
186    }
187
188    // Whatever went wrong splicing, the result is never pushed unless it
189    // loads.
190    Side::parse(&text, "the merged library")?;
191
192    Ok(Merged {
193        text,
194        received,
195        sent,
196        kept_both,
197    })
198}
199
200/// How many entries and hidden patterns differ between two copies.
201fn changes(from: &Side, to: &Side) -> usize {
202    let ids: BTreeSet<&String> = from.entries.keys().chain(to.entries.keys()).collect();
203    let entries = ids
204        .into_iter()
205        .filter(|id| from.entries.get(*id) != to.entries.get(*id))
206        .count();
207
208    entries + from.disabled.symmetric_difference(&to.disabled).count()
209}
210
211/// The first of `id-2`, `id-3` and so on that nothing is using.
212fn unused(id: &str, taken: &BTreeSet<String>) -> String {
213    (2..)
214        .map(|n| format!("{id}-{n}"))
215        .find(|candidate| !taken.contains(candidate))
216        .expect("the sequence is unbounded")
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    const HEADER: &str = "version: 1\ncommands:\n";
224
225    fn item(id: &str, desc: &str) -> String {
226        format!("  - id: {id}\n    cmd: echo {id}\n    desc: {desc}\n")
227    }
228
229    fn library(items: &[(&str, &str)]) -> String {
230        let mut text = HEADER.to_string();
231        for (id, desc) in items {
232            text.push_str(&item(id, desc));
233        }
234        text
235    }
236
237    fn entries(text: &str) -> Vec<(String, String)> {
238        definitions::parse_user(text, "test")
239            .unwrap()
240            .commands
241            .into_iter()
242            .map(|entry| (entry.id, entry.desc))
243            .collect()
244    }
245
246    fn pairs(items: &[(&str, &str)]) -> Vec<(String, String)> {
247        items
248            .iter()
249            .map(|(id, desc)| (id.to_string(), desc.to_string()))
250            .collect()
251    }
252
253    #[test]
254    fn a_first_sync_to_an_empty_repository_sends_everything() {
255        let ours = library(&[("a", "A")]);
256        let merged = merge(None, &ours, None).unwrap();
257
258        assert_eq!(merged.text, ours);
259        assert_eq!((merged.received, merged.sent), (0, 1));
260    }
261
262    #[test]
263    fn a_new_machine_takes_the_library_as_it_is() {
264        let theirs = format!("# my commands\n{}", library(&[("a", "A")]));
265        let merged = merge(None, "", Some(&theirs)).unwrap();
266
267        assert_eq!(
268            merged.text, theirs,
269            "the other machine's comments were lost"
270        );
271        assert_eq!((merged.received, merged.sent), (1, 0));
272    }
273
274    #[test]
275    fn nothing_changed_anywhere_is_nothing_to_do() {
276        let text = library(&[("a", "A")]);
277        let merged = merge(Some(&text), &text, Some(&text)).unwrap();
278
279        assert_eq!(merged.text, text);
280        assert_eq!((merged.received, merged.sent), (0, 0));
281    }
282
283    /// The case line based merging gets wrong: both machines added to the end
284    /// of the same list.
285    #[test]
286    fn two_machines_adding_different_commands_keep_both() {
287        let base = library(&[("a", "A")]);
288        let ours = library(&[("a", "A"), ("mine", "Mine")]);
289        let theirs = library(&[("a", "A"), ("theirs", "Theirs")]);
290
291        let merged = merge(Some(&base), &ours, Some(&theirs)).unwrap();
292
293        assert_eq!(
294            entries(&merged.text),
295            pairs(&[("a", "A"), ("mine", "Mine"), ("theirs", "Theirs")])
296        );
297        assert_eq!((merged.received, merged.sent), (1, 1));
298        assert!(merged.kept_both.is_empty());
299    }
300
301    #[test]
302    fn this_machines_comments_survive_a_merge() {
303        let base = library(&[("a", "A")]);
304        let ours = format!(
305            "# kept by hand\n{}",
306            library(&[("a", "A"), ("mine", "Mine")])
307        );
308        let theirs = library(&[("a", "A"), ("theirs", "Theirs")]);
309
310        let merged = merge(Some(&base), &ours, Some(&theirs)).unwrap();
311        assert!(
312            merged.text.starts_with("# kept by hand\n"),
313            "{}",
314            merged.text
315        );
316    }
317
318    #[test]
319    fn a_removal_elsewhere_reaches_this_machine() {
320        let base = library(&[("a", "A"), ("b", "B")]);
321        let ours = library(&[("a", "A"), ("b", "B"), ("mine", "Mine")]);
322        let theirs = library(&[("a", "A")]);
323
324        let merged = merge(Some(&base), &ours, Some(&theirs)).unwrap();
325        assert_eq!(
326            entries(&merged.text),
327            pairs(&[("a", "A"), ("mine", "Mine")])
328        );
329    }
330
331    #[test]
332    fn an_edit_elsewhere_reaches_this_machine() {
333        let base = library(&[("a", "A"), ("b", "B")]);
334        let ours = library(&[("a", "A"), ("b", "B"), ("mine", "Mine")]);
335        let theirs = library(&[("a", "A changed"), ("b", "B")]);
336
337        let merged = merge(Some(&base), &ours, Some(&theirs)).unwrap();
338        assert_eq!(
339            entries(&merged.text),
340            pairs(&[("a", "A changed"), ("b", "B"), ("mine", "Mine")])
341        );
342    }
343
344    #[test]
345    fn an_entry_edited_in_both_places_is_kept_twice() {
346        let base = library(&[("a", "A")]);
347        let ours = library(&[("a", "Mine")]);
348        let theirs = library(&[("a", "Theirs")]);
349
350        let merged = merge(Some(&base), &ours, Some(&theirs)).unwrap();
351
352        assert_eq!(
353            entries(&merged.text),
354            pairs(&[("a", "Theirs"), ("a-2", "Mine")])
355        );
356        assert_eq!(merged.kept_both, [("a".to_string(), "a-2".to_string())]);
357    }
358
359    /// The other machine then syncs against what this one pushed. It must
360    /// simply take the copy, not treat its own version as a fresh conflict.
361    #[test]
362    fn a_conflict_settles_after_one_round() {
363        let base = library(&[("a", "A")]);
364        let pushed_first = library(&[("a", "Theirs")]);
365        let merged = merge(Some(&base), &library(&[("a", "Mine")]), Some(&pushed_first)).unwrap();
366
367        // The machine that pushed first now syncs with no changes of its own.
368        let settled = merge(Some(&pushed_first), &pushed_first, Some(&merged.text)).unwrap();
369
370        assert_eq!(settled.text, merged.text);
371        assert!(settled.kept_both.is_empty());
372    }
373
374    #[test]
375    fn an_edit_wins_over_a_removal_on_the_other_side() {
376        let base = library(&[("a", "A"), ("b", "B")]);
377
378        let edited_here = library(&[("a", "Edited"), ("b", "B")]);
379        let removed_there = library(&[("b", "B")]);
380        let merged = merge(Some(&base), &edited_here, Some(&removed_there)).unwrap();
381        assert_eq!(entries(&merged.text), pairs(&[("a", "Edited"), ("b", "B")]));
382
383        let removed_here = library(&[("b", "B"), ("c", "C")]);
384        let edited_there = library(&[("a", "Edited"), ("b", "B")]);
385        let merged = merge(Some(&base), &removed_here, Some(&edited_there)).unwrap();
386        assert_eq!(
387            entries(&merged.text),
388            pairs(&[("b", "B"), ("c", "C"), ("a", "Edited")])
389        );
390    }
391
392    #[test]
393    fn hidden_builtins_merge_like_entries() {
394        let base = format!("{}disabled:\n  - old.*\n", library(&[("a", "A")]));
395        let ours = format!(
396            "{}disabled:\n  - old.*\n",
397            library(&[("a", "A"), ("m", "M")])
398        );
399        let theirs = format!("{}disabled:\n  - new.*\n", library(&[("a", "A")]));
400
401        let merged = merge(Some(&base), &ours, Some(&theirs)).unwrap();
402        let hidden = definitions::parse_user(&merged.text, "test")
403            .unwrap()
404            .disabled;
405
406        assert_eq!(hidden, ["new.*"]);
407        assert_eq!(entries(&merged.text), pairs(&[("a", "A"), ("m", "M")]));
408    }
409
410    #[test]
411    fn a_broken_library_is_refused_rather_than_synced() {
412        let error = merge(None, "version: 1\ncommands: [oops", None).unwrap_err();
413        assert!(format!("{error:#}").contains("this machine's library"));
414    }
415}