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 std::collections::{HashMap, HashSet};
9use std::path::Path;
10
11use dk_core::{FileAnalysis, Symbol};
12
13use crate::parser::ParserRegistry;
14
15// ── Types ────────────────────────────────────────────────────────────
16
17/// Describes a single semantic conflict within a file.
18#[derive(Debug, Clone)]
19pub struct SemanticConflict {
20    /// Path of the conflicting file.
21    pub file_path: String,
22    /// Qualified name of the symbol that conflicts.
23    pub symbol_name: String,
24    /// What our side (overlay) did to this symbol.
25    pub our_change: SymbolChangeKind,
26    /// What their side (head) did to this symbol.
27    pub their_change: SymbolChangeKind,
28}
29
30/// Classification of a symbol change relative to the base version.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum SymbolChangeKind {
33    Added,
34    Modified,
35    Removed,
36}
37
38/// Result of analyzing a file for three-way merge.
39#[derive(Debug)]
40pub enum MergeAnalysis {
41    /// No overlapping symbol changes — the file can be auto-merged.
42    AutoMerge {
43        /// The merged content (overlay content wins for non-overlapping changes).
44        merged_content: Vec<u8>,
45    },
46    /// Overlapping symbol changes that require manual resolution.
47    Conflict {
48        conflicts: Vec<SemanticConflict>,
49    },
50}
51
52// ── Analysis ─────────────────────────────────────────────────────────
53
54/// Analyze a single file for semantic conflicts across three versions.
55///
56/// - `base_content` — the file at the merge base (common ancestor).
57/// - `head_content` — the file at the current HEAD (their changes).
58/// - `overlay_content` — the file in the session overlay (our changes).
59///
60/// If parsing fails for any version (e.g. unsupported language), the
61/// function falls back to byte-level comparison: if both sides changed
62/// the file and produced different bytes, it's a conflict.
63pub fn analyze_file_conflict(
64    file_path: &str,
65    base_content: &[u8],
66    head_content: &[u8],
67    overlay_content: &[u8],
68    parser: &ParserRegistry,
69) -> MergeAnalysis {
70    let path = Path::new(file_path);
71
72    // Attempt to parse all three versions.
73    let base_parse = parser.parse_file(path, base_content);
74    let head_parse = parser.parse_file(path, head_content);
75    let overlay_parse = parser.parse_file(path, overlay_content);
76
77    match (base_parse, head_parse, overlay_parse) {
78        (Ok(base_fa), Ok(head_fa), Ok(overlay_fa)) => {
79            semantic_analysis(file_path, &base_fa, &head_fa, &overlay_fa, overlay_content)
80        }
81        _ => {
82            // Fallback: byte-level comparison.
83            byte_level_analysis(file_path, base_content, head_content, overlay_content)
84        }
85    }
86}
87
88/// Semantic three-way merge analysis using parsed symbol tables.
89fn semantic_analysis(
90    file_path: &str,
91    base: &FileAnalysis,
92    head: &FileAnalysis,
93    overlay: &FileAnalysis,
94    overlay_content: &[u8],
95) -> MergeAnalysis {
96    let base_syms = symbol_map(&base.symbols);
97    let head_syms = symbol_map(&head.symbols);
98    let overlay_syms = symbol_map(&overlay.symbols);
99
100    // Collect all symbol names across all three versions.
101    let all_names: HashSet<&str> = base_syms
102        .keys()
103        .chain(head_syms.keys())
104        .chain(overlay_syms.keys())
105        .copied()
106        .collect();
107
108    let mut conflicts = Vec::new();
109
110    for name in all_names {
111        let base_sym = base_syms.get(name);
112        let head_sym = head_syms.get(name);
113        let overlay_sym = overlay_syms.get(name);
114
115        let head_change = classify_change(base_sym, head_sym);
116        let overlay_change = classify_change(base_sym, overlay_sym);
117
118        // If both sides made a change to the same symbol, it's a conflict
119        // (unless both changes are identical).
120        if let (Some(their), Some(ours)) = (&head_change, &overlay_change) {
121            // Same kind of change — check if the results are actually identical.
122            if their == ours {
123                // Both added or both modified to the same thing — check content.
124                let identical = match (head_sym, overlay_sym) {
125                    (Some(h), Some(o)) => symbols_equivalent(h, o),
126                    (None, None) => true, // both removed
127                    _ => false,
128                };
129                if identical {
130                    continue; // No conflict — same change on both sides.
131                }
132            }
133
134            conflicts.push(SemanticConflict {
135                file_path: file_path.to_string(),
136                symbol_name: name.to_string(),
137                our_change: ours.clone(),
138                their_change: their.clone(),
139            });
140        }
141    }
142
143    if conflicts.is_empty() {
144        MergeAnalysis::AutoMerge {
145            merged_content: overlay_content.to_vec(),
146        }
147    } else {
148        MergeAnalysis::Conflict { conflicts }
149    }
150}
151
152/// Byte-level fallback when parsing is not available.
153fn byte_level_analysis(
154    file_path: &str,
155    base_content: &[u8],
156    head_content: &[u8],
157    overlay_content: &[u8],
158) -> MergeAnalysis {
159    let head_changed = base_content != head_content;
160    let overlay_changed = base_content != overlay_content;
161
162    if head_changed && overlay_changed && head_content != overlay_content {
163        // Both sides changed the same file to different content.
164        MergeAnalysis::Conflict {
165            conflicts: vec![SemanticConflict {
166                file_path: file_path.to_string(),
167                symbol_name: "<entire file>".to_string(),
168                our_change: SymbolChangeKind::Modified,
169                their_change: SymbolChangeKind::Modified,
170            }],
171        }
172    } else {
173        // Either only one side changed, or both changed identically.
174        // Use overlay content (our changes take precedence for non-conflicts).
175        MergeAnalysis::AutoMerge {
176            merged_content: if overlay_changed {
177                overlay_content.to_vec()
178            } else {
179                head_content.to_vec()
180            },
181        }
182    }
183}
184
185/// Build a map of qualified_name -> Symbol for quick lookup.
186fn symbol_map(symbols: &[Symbol]) -> HashMap<&str, &Symbol> {
187    symbols
188        .iter()
189        .map(|s| (s.qualified_name.as_str(), s))
190        .collect()
191}
192
193/// Classify how a symbol changed from base to the given version.
194fn classify_change(
195    base: Option<&&Symbol>,
196    current: Option<&&Symbol>,
197) -> Option<SymbolChangeKind> {
198    match (base, current) {
199        (None, None) => None,
200        (None, Some(_)) => Some(SymbolChangeKind::Added),
201        (Some(_), None) => Some(SymbolChangeKind::Removed),
202        (Some(b), Some(c)) => {
203            if symbols_equivalent(b, c) {
204                None // unchanged
205            } else {
206                Some(SymbolChangeKind::Modified)
207            }
208        }
209    }
210}
211
212/// Check whether two symbols are semantically equivalent for merge purposes.
213/// We compare span, kind, visibility, and signature — NOT the symbol ID.
214fn symbols_equivalent(a: &Symbol, b: &Symbol) -> bool {
215    a.qualified_name == b.qualified_name
216        && a.kind == b.kind
217        && a.visibility == b.visibility
218        && a.span == b.span
219        && a.signature == b.signature
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn byte_level_no_conflict_when_only_overlay_changed() {
228        let base = b"base content";
229        let head = b"base content"; // unchanged
230        let overlay = b"overlay content";
231
232        match byte_level_analysis("test.txt", base, head, overlay) {
233            MergeAnalysis::AutoMerge { merged_content } => {
234                assert_eq!(merged_content, overlay.to_vec());
235            }
236            MergeAnalysis::Conflict { .. } => panic!("expected auto-merge"),
237        }
238    }
239
240    #[test]
241    fn byte_level_no_conflict_when_only_head_changed() {
242        let base = b"base content";
243        let head = b"head content";
244        let overlay = b"base content"; // unchanged
245
246        match byte_level_analysis("test.txt", base, head, overlay) {
247            MergeAnalysis::AutoMerge { merged_content } => {
248                assert_eq!(merged_content, head.to_vec());
249            }
250            MergeAnalysis::Conflict { .. } => panic!("expected auto-merge"),
251        }
252    }
253
254    #[test]
255    fn byte_level_conflict_when_both_changed_differently() {
256        let base = b"base content";
257        let head = b"head content";
258        let overlay = b"overlay content";
259
260        match byte_level_analysis("test.txt", base, head, overlay) {
261            MergeAnalysis::Conflict { conflicts } => {
262                assert_eq!(conflicts.len(), 1);
263                assert_eq!(conflicts[0].symbol_name, "<entire file>");
264            }
265            MergeAnalysis::AutoMerge { .. } => panic!("expected conflict"),
266        }
267    }
268
269    #[test]
270    fn byte_level_no_conflict_when_both_changed_identically() {
271        let base = b"base content";
272        let same = b"same content";
273
274        match byte_level_analysis("test.txt", base, same, same) {
275            MergeAnalysis::AutoMerge { .. } => {} // OK
276            MergeAnalysis::Conflict { .. } => panic!("expected auto-merge"),
277        }
278    }
279
280    #[test]
281    fn classify_change_cases() {
282        use dk_core::{Span, SymbolKind, Visibility};
283        use std::path::PathBuf;
284        use uuid::Uuid;
285
286        let sym = Symbol {
287            id: Uuid::new_v4(),
288            name: "f".into(),
289            qualified_name: "f".into(),
290            kind: SymbolKind::Function,
291            visibility: Visibility::Public,
292            file_path: PathBuf::from("t.rs"),
293            span: Span {
294                start_byte: 0,
295                end_byte: 10,
296            },
297            signature: None,
298            doc_comment: None,
299            parent: None,
300            last_modified_by: None,
301            last_modified_intent: None,
302        };
303
304        // None -> None => no change
305        assert!(classify_change(None, None).is_none());
306
307        // None -> Some => Added
308        assert_eq!(
309            classify_change(None, Some(&&sym)),
310            Some(SymbolChangeKind::Added)
311        );
312
313        // Some -> None => Removed
314        assert_eq!(
315            classify_change(Some(&&sym), None),
316            Some(SymbolChangeKind::Removed)
317        );
318
319        // Some -> Some (identical) => no change
320        assert!(classify_change(Some(&&sym), Some(&&sym)).is_none());
321    }
322}