Skip to main content

vyn_core/
merge.rs

1use similar::{ChangeTag, TextDiff};
2
3pub enum MergeOutcome {
4    Merged(String),
5    Conflicted(String),
6}
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum BinaryMergeDecision {
10    NoConflict,
11    KeepBoth,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15enum Op {
16    Kept(String),     // side kept the base line unchanged
17    Changed(String),  // side replaced the base line with new content
18    Deleted,          // side deleted the base line
19    Inserted(String), // side inserted a line with no base counterpart
20}
21
22/// True LCS-based 3-way merge. Lines only one side changed are taken
23/// automatically; lines both sides changed differently produce conflict markers.
24pub fn merge_text(base: &str, local: &str, remote: &str) -> MergeOutcome {
25    if local == remote {
26        return MergeOutcome::Merged(local.to_string());
27    }
28    if local == base {
29        return MergeOutcome::Merged(remote.to_string());
30    }
31    if remote == base {
32        return MergeOutcome::Merged(local.to_string());
33    }
34
35    let local_ops = side_ops(base, local);
36    let remote_ops = side_ops(base, remote);
37
38    let n = local_ops.len().max(remote_ops.len());
39    let mut merged: Vec<String> = Vec::new();
40    let mut has_conflict = false;
41
42    for i in 0..n {
43        let l = local_ops.get(i);
44        let r = remote_ops.get(i);
45
46        match (l, r) {
47            // Both kept the same base line (or both agree on the same result)
48            (Some(Op::Kept(ls)), Some(Op::Kept(_))) => merged.push(ls.clone()),
49
50            // Only local changed; remote kept base -- take local
51            (Some(Op::Changed(ls)), Some(Op::Kept(_))) => merged.push(ls.clone()),
52            (Some(Op::Inserted(ls)), None) => merged.push(ls.clone()),
53
54            // Only remote changed; local kept base -- take remote
55            (Some(Op::Kept(_)), Some(Op::Changed(rs))) => merged.push(rs.clone()),
56            (None, Some(Op::Inserted(rs))) => merged.push(rs.clone()),
57
58            // Both inserted identical content
59            (Some(Op::Inserted(ls)), Some(Op::Inserted(rs))) if ls == rs => merged.push(ls.clone()),
60
61            // Both changed to the same value -- clean
62            (Some(Op::Changed(ls)), Some(Op::Changed(rs))) if ls == rs => merged.push(ls.clone()),
63
64            // Both deleted
65            (Some(Op::Deleted), Some(Op::Deleted)) => {}
66
67            // Local deleted, remote kept base -- take delete
68            (Some(Op::Deleted), Some(Op::Kept(_))) => {}
69
70            // Remote deleted, local kept base -- take delete
71            (Some(Op::Kept(_)), Some(Op::Deleted)) => {}
72
73            // Everything else is a conflict
74            (l, r) => {
75                has_conflict = true;
76                merged.push("<<<<<<< LOCAL".to_string());
77                match l {
78                    Some(Op::Kept(s) | Op::Changed(s) | Op::Inserted(s)) => merged.push(s.clone()),
79                    Some(Op::Deleted) | None => {}
80                }
81                merged.push("=======".to_string());
82                match r {
83                    Some(Op::Kept(s) | Op::Changed(s) | Op::Inserted(s)) => merged.push(s.clone()),
84                    Some(Op::Deleted) | None => {}
85                }
86                merged.push(">>>>>>> REMOTE".to_string());
87            }
88        }
89    }
90
91    let body = if merged.is_empty() {
92        String::new()
93    } else {
94        format!("{}\n", merged.join("\n"))
95    };
96
97    if has_conflict {
98        MergeOutcome::Conflicted(body)
99    } else {
100        MergeOutcome::Merged(body)
101    }
102}
103
104/// For each line in `base`, compute what `side` does with it.
105/// Pure insertions (no base counterpart) are appended at the end.
106fn side_ops(base: &str, side: &str) -> Vec<Op> {
107    let diff = TextDiff::from_lines(base, side);
108    let changes: Vec<_> = diff.iter_all_changes().collect();
109    let mut ops: Vec<Op> = Vec::new();
110
111    let mut i = 0;
112    while i < changes.len() {
113        match changes[i].tag() {
114            ChangeTag::Equal => {
115                ops.push(Op::Kept(
116                    changes[i].value().trim_end_matches('\n').to_string(),
117                ));
118                i += 1;
119            }
120            ChangeTag::Delete => {
121                // A Delete immediately followed by an Insert = replacement
122                if i + 1 < changes.len() && changes[i + 1].tag() == ChangeTag::Insert {
123                    ops.push(Op::Changed(
124                        changes[i + 1].value().trim_end_matches('\n').to_string(),
125                    ));
126                    i += 2;
127                } else {
128                    ops.push(Op::Deleted);
129                    i += 1;
130                }
131            }
132            ChangeTag::Insert => {
133                // Pure insertion: no base line consumed
134                ops.push(Op::Inserted(
135                    changes[i].value().trim_end_matches('\n').to_string(),
136                ));
137                i += 1;
138            }
139        }
140    }
141
142    ops
143}
144
145pub fn detect_binary_conflict(base: &[u8], local: &[u8], remote: &[u8]) -> BinaryMergeDecision {
146    if local == remote || local == base || remote == base {
147        BinaryMergeDecision::NoConflict
148    } else {
149        BinaryMergeDecision::KeepBoth
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::{BinaryMergeDecision, MergeOutcome, detect_binary_conflict, merge_text};
156
157    #[test]
158    fn merge_non_overlapping_edits() {
159        let base = "A=1\nB=2\n";
160        let local = "A=9\nB=2\n";
161        let remote = "A=1\nB=8\n";
162
163        match merge_text(base, local, remote) {
164            MergeOutcome::Merged(merged) => {
165                assert!(merged.contains("A=9"), "local change missing");
166                assert!(merged.contains("B=8"), "remote change missing");
167            }
168            MergeOutcome::Conflicted(_) => panic!("expected auto merge for non-overlapping edits"),
169        }
170    }
171
172    #[test]
173    fn merge_overlapping_conflict() {
174        let conflict_local = "A=9\n";
175        let conflict_remote = "A=8\n";
176        match merge_text("A=1\n", conflict_local, conflict_remote) {
177            MergeOutcome::Conflicted(text) => {
178                assert!(text.contains("<<<<<<< LOCAL"));
179                assert!(text.contains(">>>>>>> REMOTE"));
180            }
181            MergeOutcome::Merged(_) => panic!("expected conflict markers for overlapping edits"),
182        }
183    }
184
185    #[test]
186    fn merge_insertion_by_one_side() {
187        let base = "line1\nline2\n";
188        let local = "line1\nline2\nline3\n";
189        let remote = "line1\nline2\n";
190        match merge_text(base, local, remote) {
191            MergeOutcome::Merged(m) => assert!(m.contains("line3")),
192            MergeOutcome::Conflicted(_) => panic!("expected clean merge"),
193        }
194    }
195
196    #[test]
197    fn binary_conflict_detection() {
198        let base = b"abc";
199        let local = b"abc";
200        let remote = b"abd";
201        assert_eq!(
202            detect_binary_conflict(base, local, remote),
203            BinaryMergeDecision::NoConflict
204        );
205
206        let local = b"xyz";
207        let remote = b"123";
208        assert_eq!(
209            detect_binary_conflict(base, local, remote),
210            BinaryMergeDecision::KeepBoth
211        );
212    }
213}