1use std::collections::{BTreeMap, BTreeSet};
10
11use anyhow::Result;
12
13use crate::model::Entry;
14use crate::store::definitions::{self, NewEntry};
15
16#[derive(Debug)]
18pub struct Merged {
19 pub text: String,
21 pub received: usize,
23 pub sent: usize,
25 pub kept_both: Vec<(String, String)>,
29}
30
31struct Side {
33 entries: BTreeMap<String, Entry>,
34 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
58pub 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 if mine == other || other == was {
127 continue;
128 }
129
130 let untouched_here = mine == was;
131 received += 1;
132 match (mine, other) {
133 (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 (Some(_), None) if untouched_here => {
154 text = definitions::removed(&text, &id).unwrap_or(text);
155 }
156 (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 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 Side::parse(&text, "the merged library")?;
191
192 Ok(Merged {
193 text,
194 received,
195 sent,
196 kept_both,
197 })
198}
199
200fn 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
211fn 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 #[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 #[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 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}