Skip to main content

dk_engine/workspace/
conflict.rs

1//! Semantic conflict detection for three-way merge.
2//!
3//! Instead of purely textual diff3, this module parses all three versions
4//! of a file (base, head, overlay) with tree-sitter and compares the
5//! resulting symbol tables. Conflicts arise when both sides modify,
6//! add, or remove the *same* symbol.
7
8use crate::conflict::ast_merge;
9use crate::parser::ParserRegistry;
10
11// ── Types ────────────────────────────────────────────────────────────
12
13/// Describes a single semantic conflict within a file.
14#[derive(Debug, Clone)]
15pub struct SemanticConflict {
16    /// Path of the conflicting file.
17    pub file_path: String,
18    /// Qualified name of the symbol that conflicts.
19    pub symbol_name: String,
20    /// What our side (overlay) did to this symbol.
21    pub our_change: SymbolChangeKind,
22    /// What their side (head) did to this symbol.
23    pub their_change: SymbolChangeKind,
24}
25
26/// Classification of a symbol change relative to the base version.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum SymbolChangeKind {
29    Added,
30    Modified,
31    Removed,
32}
33
34/// Result of analyzing a file for three-way merge.
35#[derive(Debug)]
36pub enum MergeAnalysis {
37    /// No overlapping symbol changes — the file can be auto-merged.
38    AutoMerge {
39        /// The merged content (overlay content wins for non-overlapping changes).
40        merged_content: Vec<u8>,
41    },
42    /// Overlapping symbol changes that require manual resolution.
43    Conflict {
44        conflicts: Vec<SemanticConflict>,
45    },
46}
47
48// ── Analysis ─────────────────────────────────────────────────────────
49
50/// Analyze a single file for semantic conflicts across three versions.
51///
52/// - `base_content` — the file at the merge base (common ancestor).
53/// - `head_content` — the file at the current HEAD (their changes).
54/// - `overlay_content` — the file in the session overlay (our changes).
55///
56/// If parsing fails for any version (e.g. unsupported language), the
57/// function falls back to byte-level comparison: if both sides changed
58/// the file and produced different bytes, it's a conflict.
59pub fn analyze_file_conflict(
60    file_path: &str,
61    base_content: &[u8],
62    head_content: &[u8],
63    overlay_content: &[u8],
64    parser: &ParserRegistry,
65) -> MergeAnalysis {
66    // Try AST-level three-way merge first. This produces proper merged
67    // content that combines non-overlapping symbol changes from both sides,
68    // instead of returning only one side's content.
69    let base_str = std::str::from_utf8(base_content).ok();
70    let head_str = std::str::from_utf8(head_content).ok();
71    let overlay_str = std::str::from_utf8(overlay_content).ok();
72
73    if let (Some(base), Some(head), Some(overlay)) = (base_str, head_str, overlay_str) {
74        match ast_merge::ast_merge(parser, file_path, base, head, overlay) {
75            Ok(result) => {
76                return match result.status {
77                    ast_merge::MergeStatus::Clean => {
78                        // Guard: ast_merge reconstructs files from imports + symbols only.
79                        // Top-level items the parser doesn't classify as symbols (const,
80                        // static, type aliases, mod declarations, crate attributes) are
81                        // silently dropped.  Detect content loss by checking that the
82                        // merged output is at least 80% of the smaller agent version.
83                        // We compare against min(head, overlay) because a legitimate
84                        // large deletion correctly shrinks the output — the guard should
85                        // only fire when the merge result is smaller than even the
86                        // most-deleting agent's output, which is a strong signal that
87                        // ast_merge dropped content it shouldn't have.
88                        // Note: uses `merged * 5 < min * 4` instead of
89                        // `merged * 100 / min < 80` to avoid usize overflow on
90                        // large files (>42 MB on 32-bit hosts).
91                        let merged_len = result.merged_content.len();
92                        let min_agent_len = head.len().min(overlay.len());
93                        if min_agent_len > 0 && merged_len * 5 < min_agent_len * 4 {
94                            byte_level_analysis(
95                                file_path,
96                                base_content,
97                                head_content,
98                                overlay_content,
99                            )
100                        } else {
101                            MergeAnalysis::AutoMerge {
102                                merged_content: result.merged_content.into_bytes(),
103                            }
104                        }
105                    }
106                    ast_merge::MergeStatus::Conflict => MergeAnalysis::Conflict {
107                        conflicts: result
108                            .conflicts
109                            .into_iter()
110                            .map(|c| {
111                                // Infer change kinds from the three-way symbol versions:
112                                // - version_a = head (their), version_b = overlay (our)
113                                // - empty string means the symbol does not exist in that version
114                                let their_change = infer_change_kind(&c.base, &c.version_a);
115                                let our_change = infer_change_kind(&c.base, &c.version_b);
116                                SemanticConflict {
117                                    file_path: file_path.to_string(),
118                                    symbol_name: c.qualified_name,
119                                    our_change,
120                                    their_change,
121                                }
122                            })
123                            .collect(),
124                    },
125                };
126            }
127            Err(e) => {
128                tracing::debug!(
129                    file_path,
130                    error = %e,
131                    "ast_merge failed, falling back to byte-level analysis"
132                );
133            }
134        }
135    }
136
137    // Fallback: byte-level comparison when AST merge is not available
138    // (binary files, unsupported languages, or UTF-8 decode failure).
139    byte_level_analysis(file_path, base_content, head_content, overlay_content)
140}
141
142/// Byte-level fallback when parsing is not available.
143fn byte_level_analysis(
144    file_path: &str,
145    base_content: &[u8],
146    head_content: &[u8],
147    overlay_content: &[u8],
148) -> MergeAnalysis {
149    let head_changed = base_content != head_content;
150    let overlay_changed = base_content != overlay_content;
151
152    if head_changed && overlay_changed && head_content != overlay_content {
153        // Both sides changed the same file to different content.
154        MergeAnalysis::Conflict {
155            conflicts: vec![SemanticConflict {
156                file_path: file_path.to_string(),
157                symbol_name: "<entire file>".to_string(),
158                our_change: SymbolChangeKind::Modified,
159                their_change: SymbolChangeKind::Modified,
160            }],
161        }
162    } else {
163        // Either only one side changed, or both changed identically.
164        // Use overlay content (our changes take precedence for non-conflicts).
165        MergeAnalysis::AutoMerge {
166            merged_content: if overlay_changed {
167                overlay_content.to_vec()
168            } else {
169                head_content.to_vec()
170            },
171        }
172    }
173}
174
175/// Infer the [`SymbolChangeKind`] by comparing a symbol's base version to its
176/// current version.  An empty string means the symbol does not exist in that
177/// version of the file.
178fn infer_change_kind(base: &str, current: &str) -> SymbolChangeKind {
179    match (base.is_empty(), current.is_empty()) {
180        // Symbol absent in base, present now — added
181        (true, false) => SymbolChangeKind::Added,
182        // Symbol present in base, absent now — removed
183        (false, true) => SymbolChangeKind::Removed,
184        // Both present — a genuine modification (content must differ; ast_merge
185        // only emits conflicts when at least one side changed).
186        (false, false) => SymbolChangeKind::Modified,
187        // Both absent — ast_merge never generates this as a conflict; treat as
188        // Modified defensively but this branch should be unreachable.
189        (true, true) => SymbolChangeKind::Modified,
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn byte_level_no_conflict_when_only_overlay_changed() {
199        let base = b"base content";
200        let head = b"base content"; // unchanged
201        let overlay = b"overlay content";
202
203        match byte_level_analysis("test.txt", base, head, overlay) {
204            MergeAnalysis::AutoMerge { merged_content } => {
205                assert_eq!(merged_content, overlay.to_vec());
206            }
207            MergeAnalysis::Conflict { .. } => panic!("expected auto-merge"),
208        }
209    }
210
211    #[test]
212    fn byte_level_no_conflict_when_only_head_changed() {
213        let base = b"base content";
214        let head = b"head content";
215        let overlay = b"base content"; // unchanged
216
217        match byte_level_analysis("test.txt", base, head, overlay) {
218            MergeAnalysis::AutoMerge { merged_content } => {
219                assert_eq!(merged_content, head.to_vec());
220            }
221            MergeAnalysis::Conflict { .. } => panic!("expected auto-merge"),
222        }
223    }
224
225    #[test]
226    fn byte_level_conflict_when_both_changed_differently() {
227        let base = b"base content";
228        let head = b"head content";
229        let overlay = b"overlay content";
230
231        match byte_level_analysis("test.txt", base, head, overlay) {
232            MergeAnalysis::Conflict { conflicts } => {
233                assert_eq!(conflicts.len(), 1);
234                assert_eq!(conflicts[0].symbol_name, "<entire file>");
235            }
236            MergeAnalysis::AutoMerge { .. } => panic!("expected conflict"),
237        }
238    }
239
240    #[test]
241    fn byte_level_no_conflict_when_both_changed_identically() {
242        let base = b"base content";
243        let same = b"same content";
244
245        match byte_level_analysis("test.txt", base, same, same) {
246            MergeAnalysis::AutoMerge { .. } => {} // OK
247            MergeAnalysis::Conflict { .. } => panic!("expected auto-merge"),
248        }
249    }
250
251    #[test]
252    fn infer_change_kind_added() {
253        assert_eq!(infer_change_kind("", "fn new() {}"), SymbolChangeKind::Added);
254    }
255
256    #[test]
257    fn infer_change_kind_removed() {
258        assert_eq!(infer_change_kind("fn old() {}", ""), SymbolChangeKind::Removed);
259    }
260
261    #[test]
262    fn infer_change_kind_modified() {
263        assert_eq!(
264            infer_change_kind("fn foo() { 1 }", "fn foo() { 2 }"),
265            SymbolChangeKind::Modified
266        );
267    }
268
269    #[test]
270    fn infer_change_kind_both_empty() {
271        // Edge case: both empty — ast_merge never emits this, but return Modified defensively
272        assert_eq!(infer_change_kind("", ""), SymbolChangeKind::Modified);
273    }
274}