headless_lms_utils/
merge_edits.rs

1pub fn merge(ancestor: &str, incoming_edit: &str, current: &str) -> Option<String> {
2    if ancestor == current {
3        // if there have been no changes between the proposal and now, no need to merge
4        return Some(incoming_edit.to_string());
5    }
6
7    let mut incoming = diff::chars(ancestor, incoming_edit).into_iter().peekable();
8    let mut existing = diff::chars(ancestor, current).into_iter().peekable();
9
10    let mut result = String::new();
11    'outer: loop {
12        match incoming.next() {
13            // char was unchanged in the incoming update, accept whatever changes exist in current
14            Some(diff::Result::Both(inc_left, _)) => loop {
15                match existing.next() {
16                    // char was also unchanged in the existing changes, push the char to result
17                    Some(diff::Result::Both(..)) => {
18                        result.push(inc_left);
19                        break;
20                    }
21                    // char was removed in the existing update, skip over it in the result
22                    Some(diff::Result::Left(_)) => break,
23                    // a char was added in the existing update, add it to the result and keep reading the existing changes
24                    Some(diff::Result::Right(right)) => result.push(right),
25                    // unexpectedly ran out of existing changes, fail merge
26                    None => return None,
27                }
28            },
29            // char was removed in the incoming update
30            Some(diff::Result::Left(_)) => match existing.next() {
31                // char was unchanged in the existing changes, skip over it in the result
32                Some(diff::Result::Both(..)) => continue,
33                // char was also removed in the existing changes, skip over it in the result
34                Some(diff::Result::Left(_)) => continue,
35                // a char was added in existing changes, hard to say what should be done here
36                // ignoring the addition seems to produce the best results
37                Some(diff::Result::Right(_)) => continue,
38                // unexpectedly ran out of existing changes, fail merge
39                None => return None,
40            },
41            // char was added in the incoming update
42            Some(diff::Result::Right(inc_right)) => {
43                // check the next diff in existing to avoid duplicate additions
44                if let Some(diff::Result::Right(next_existing)) = existing.peek() {
45                    // same character added in both updates, skip over it in existing
46                    if next_existing == &inc_right {
47                        existing.next();
48                    }
49                }
50                result.push(inc_right)
51            }
52            // end of incoming changes
53            None => loop {
54                match existing.next() {
55                    // unchanged in the existing changes
56                    Some(diff::Result::Both(..)) => continue,
57                    // removed in the existing changes
58                    Some(diff::Result::Left(_)) => continue,
59                    // new in the existing changes
60                    Some(diff::Result::Right(right)) => result.push(right),
61                    // end of changes
62                    None => break 'outer,
63                }
64            },
65        }
66    }
67    Some(result)
68}
69
70#[cfg(test)]
71mod test {
72    use super::*;
73
74    #[test]
75    fn typo_and_append() {
76        let ancestor = "This is the original, uneditd text.";
77        let incoming =
78            "This is the original, uneditd text, with more information written at the end.";
79        let current = "This is the original, unedited text.";
80        assert_eq!(
81            merge(ancestor, incoming, current).unwrap(),
82            "This is the original, unedited text, with more information written at the end."
83        );
84    }
85
86    #[test]
87    fn typo_and_prepend() {
88        let ancestor = "This is the original, uneditd text.";
89        let incoming = "I added some things, but this is the original, uneditd text.";
90        let current = "This is the original, unedited text.";
91        assert_eq!(
92            merge(ancestor, incoming, current).unwrap(),
93            "I added some things, but this is the original, unedited text."
94        );
95    }
96
97    #[test]
98    fn typo_and_middle() {
99        let ancestor = "This is the original, uneditd text.";
100        let incoming = "This is the original, completely uneditd text.";
101        let current = "This is the original, unedited text.";
102        assert_eq!(
103            merge(ancestor, incoming, current).unwrap(),
104            "This is the original, completely unedited text."
105        );
106    }
107
108    #[test]
109    fn rewrite() {
110        let ancestor = "This is the original, uneditd text.";
111        let incoming = "I decided to rewrite this paragraph!";
112        let current = "This is the original, unedited text.";
113        assert_eq!(
114            merge(ancestor, incoming, current).unwrap(),
115            "I decided to rewrite this paragraph!"
116        );
117    }
118
119    #[test]
120    fn same_edit_applied_twice() {
121        let ancestor = "This is the original, uneditd text.";
122        let incoming = "This is the original, unedited text.";
123        let current = "This is the original, unedited text.";
124        assert_eq!(
125            merge(ancestor, incoming, current).unwrap(),
126            "This is the original, unedited text."
127        );
128    }
129
130    #[test]
131    fn regression_test() {
132        let ancestor = "paragraphs.";
133        let incoming = "pgraphs.";
134        let current = "paragraphs!";
135        assert_eq!(merge(ancestor, incoming, current).unwrap(), "pgraphs!");
136    }
137}