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}