Skip to main content

weave_core/
conflict.rs

1use std::fmt;
2use serde::Serialize;
3
4/// Controls conflict marker format: enhanced (weave metadata) or standard (git-compatible).
5///
6/// When `enhanced` is true (default for git merge driver), markers include entity metadata,
7/// ConGra complexity classification, and resolution hints.
8/// When `enhanced` is false (triggered by `-l` flag from jj/other tools), markers use
9/// the standard git format that tools can parse: `<<<<<<< ours` / `=======` / `>>>>>>> theirs`.
10#[derive(Debug, Clone)]
11pub struct MarkerFormat {
12    pub marker_length: usize,
13    pub enhanced: bool,
14}
15
16impl Default for MarkerFormat {
17    fn default() -> Self {
18        Self { marker_length: 7, enhanced: true }
19    }
20}
21
22impl MarkerFormat {
23    pub fn standard(marker_length: usize) -> Self {
24        Self { marker_length, enhanced: false }
25    }
26}
27
28/// The type of conflict between two branches' changes to an entity.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ConflictKind {
31    /// Both branches modified the same entity and the changes couldn't be merged.
32    BothModified,
33    /// One branch modified the entity while the other deleted it.
34    ModifyDelete { modified_in_ours: bool },
35    /// Both branches added an entity with the same ID but different content.
36    BothAdded,
37    /// Both branches renamed the same entity to different names.
38    RenameRename {
39        base_name: String,
40        ours_name: String,
41        theirs_name: String,
42    },
43}
44
45impl fmt::Display for ConflictKind {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            ConflictKind::BothModified => write!(f, "both modified"),
49            ConflictKind::ModifyDelete {
50                modified_in_ours: true,
51            } => write!(f, "modified in ours, deleted in theirs"),
52            ConflictKind::ModifyDelete {
53                modified_in_ours: false,
54            } => write!(f, "deleted in ours, modified in theirs"),
55            ConflictKind::BothAdded => write!(f, "both added"),
56            ConflictKind::RenameRename { base_name, ours_name, theirs_name } => {
57                write!(f, "both renamed: '{}' → ours '{}', theirs '{}'", base_name, ours_name, theirs_name)
58            }
59        }
60    }
61}
62
63/// Conflict complexity classification (ConGra taxonomy, arXiv:2409.14121).
64///
65/// Helps agents and tools choose appropriate resolution strategies:
66/// - Text: trivial, usually auto-resolvable (comment changes)
67/// - Syntax: signature/type changes, may need type-checking
68/// - Functional: body logic changes, needs careful review
69/// - Composite variants indicate multiple dimensions of change.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum ConflictComplexity {
72    /// Only text/comment/string changes
73    Text,
74    /// Signature, type, or structural changes (no body changes)
75    Syntax,
76    /// Function body / logic changes
77    Functional,
78    /// Both text and syntax changes
79    TextSyntax,
80    /// Both text and functional changes
81    TextFunctional,
82    /// Both syntax and functional changes
83    SyntaxFunctional,
84    /// All three dimensions changed
85    TextSyntaxFunctional,
86    /// Could not classify (e.g., unknown entity type)
87    Unknown,
88}
89
90impl ConflictComplexity {
91    /// Human-readable resolution hint for this conflict type.
92    pub fn resolution_hint(&self) -> &'static str {
93        match self {
94            ConflictComplexity::Text =>
95                "Cosmetic change on both sides. Pick either version or combine formatting.",
96            ConflictComplexity::Syntax =>
97                "Structural change (rename/retype). Check callers of this entity.",
98            ConflictComplexity::Functional =>
99                "Logic changed on both sides. Requires understanding intent of each change.",
100            ConflictComplexity::TextSyntax =>
101                "Renamed and reformatted. Prefer the structural change, verify formatting.",
102            ConflictComplexity::TextFunctional =>
103                "Logic and cosmetic changes overlap. Resolve logic first, then reformat.",
104            ConflictComplexity::SyntaxFunctional =>
105                "Structural and logic conflict. Both design and behavior differ.",
106            ConflictComplexity::TextSyntaxFunctional =>
107                "All three dimensions conflict. Manual review required.",
108            ConflictComplexity::Unknown =>
109                "Could not classify. Compare both versions manually.",
110        }
111    }
112}
113
114impl fmt::Display for ConflictComplexity {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        match self {
117            ConflictComplexity::Text => write!(f, "T"),
118            ConflictComplexity::Syntax => write!(f, "S"),
119            ConflictComplexity::Functional => write!(f, "F"),
120            ConflictComplexity::TextSyntax => write!(f, "T+S"),
121            ConflictComplexity::TextFunctional => write!(f, "T+F"),
122            ConflictComplexity::SyntaxFunctional => write!(f, "S+F"),
123            ConflictComplexity::TextSyntaxFunctional => write!(f, "T+S+F"),
124            ConflictComplexity::Unknown => write!(f, "?"),
125        }
126    }
127}
128
129/// Classify conflict complexity by analyzing what changed between versions.
130pub fn classify_conflict(base: Option<&str>, ours: Option<&str>, theirs: Option<&str>) -> ConflictComplexity {
131    let base = base.unwrap_or("");
132    let ours = ours.unwrap_or("");
133    let theirs = theirs.unwrap_or("");
134
135    // Compare ours and theirs changes vs base
136    let ours_diff = classify_change(base, ours);
137    let theirs_diff = classify_change(base, theirs);
138
139    // Merge the dimensions
140    let has_text = ours_diff.text || theirs_diff.text;
141    let has_syntax = ours_diff.syntax || theirs_diff.syntax;
142    let has_functional = ours_diff.functional || theirs_diff.functional;
143
144    match (has_text, has_syntax, has_functional) {
145        (true, false, false) => ConflictComplexity::Text,
146        (false, true, false) => ConflictComplexity::Syntax,
147        (false, false, true) => ConflictComplexity::Functional,
148        (true, true, false) => ConflictComplexity::TextSyntax,
149        (true, false, true) => ConflictComplexity::TextFunctional,
150        (false, true, true) => ConflictComplexity::SyntaxFunctional,
151        (true, true, true) => ConflictComplexity::TextSyntaxFunctional,
152        (false, false, false) => ConflictComplexity::Unknown,
153    }
154}
155
156struct ChangeDimensions {
157    text: bool,
158    syntax: bool,
159    functional: bool,
160}
161
162/// Find the end of the signature in a function/method definition.
163/// Handles multi-line parameter lists by tracking parenthesis depth.
164/// Returns the index (exclusive) of the first body line after the signature.
165fn find_signature_end(lines: &[&str]) -> usize {
166    if lines.is_empty() {
167        return 0;
168    }
169    let mut depth: i32 = 0;
170    for (i, line) in lines.iter().enumerate() {
171        for ch in line.chars() {
172            match ch {
173                '(' => depth += 1,
174                ')' => depth -= 1,
175                '{' | ':' if depth <= 0 && i > 0 => {
176                    // Body start: signature ends at this line (inclusive)
177                    return i + 1;
178                }
179                _ => {}
180            }
181        }
182        // If we opened parens and closed them all on this line or previous,
183        // and the next line starts a body block, the signature ends here
184        if depth <= 0 && i > 0 {
185            // Check if this line ends the signature (has closing paren and body opener)
186            let trimmed = line.trim();
187            if trimmed.ends_with('{') || trimmed.ends_with(':') || trimmed.ends_with("->") {
188                return i + 1;
189            }
190        }
191    }
192    // Fallback: first line is signature
193    1
194}
195
196fn classify_change(base: &str, modified: &str) -> ChangeDimensions {
197    if base == modified {
198        return ChangeDimensions {
199            text: false,
200            syntax: false,
201            functional: false,
202        };
203    }
204
205    let base_lines: Vec<&str> = base.lines().collect();
206    let modified_lines: Vec<&str> = modified.lines().collect();
207
208    let mut has_comment_change = false;
209    let mut has_signature_change = false;
210    let mut has_body_change = false;
211
212    // Find signature end for multi-line signatures
213    let base_sig_end = find_signature_end(&base_lines);
214    let mod_sig_end = find_signature_end(&modified_lines);
215
216    // Check signature (may span multiple lines)
217    let base_sig: Vec<&str> = base_lines.iter().take(base_sig_end).copied().collect();
218    let mod_sig: Vec<&str> = modified_lines.iter().take(mod_sig_end).copied().collect();
219    if base_sig != mod_sig {
220        let all_comments = base_sig.iter().all(|l| is_comment_line(l))
221            && mod_sig.iter().all(|l| is_comment_line(l));
222        if all_comments {
223            has_comment_change = true;
224        } else {
225            has_signature_change = true;
226        }
227    }
228
229    // Check body lines
230    let base_body: Vec<&str> = base_lines.iter().skip(base_sig_end).copied().collect();
231    let mod_body: Vec<&str> = modified_lines.iter().skip(mod_sig_end).copied().collect();
232
233    if base_body != mod_body {
234        // Check if changes are only in comments
235        let base_no_comments: Vec<&str> = base_body
236            .iter()
237            .filter(|l| !is_comment_line(l))
238            .copied()
239            .collect();
240        let mod_no_comments: Vec<&str> = mod_body
241            .iter()
242            .filter(|l| !is_comment_line(l))
243            .copied()
244            .collect();
245
246        if base_no_comments == mod_no_comments {
247            has_comment_change = true;
248        } else {
249            has_body_change = true;
250        }
251    }
252
253    ChangeDimensions {
254        text: has_comment_change,
255        syntax: has_signature_change,
256        functional: has_body_change,
257    }
258}
259
260fn is_comment_line(line: &str) -> bool {
261    let trimmed = line.trim();
262    trimmed.starts_with("//")
263        || trimmed.starts_with("/*")
264        || trimmed.starts_with("*")
265        || trimmed.starts_with("#")
266        || trimmed.starts_with("\"\"\"")
267        || trimmed.starts_with("'''")
268}
269
270/// A conflict on a specific entity.
271#[derive(Debug, Clone)]
272pub struct EntityConflict {
273    pub entity_name: String,
274    pub entity_type: String,
275    pub kind: ConflictKind,
276    pub complexity: ConflictComplexity,
277    pub ours_content: Option<String>,
278    pub theirs_content: Option<String>,
279    pub base_content: Option<String>,
280}
281
282/// Find common prefix and suffix lines between two texts.
283/// Returns (prefix_lines, ours_middle_lines, theirs_middle_lines, suffix_lines).
284pub fn narrow_conflict_lines<'a>(
285    ours_lines: &'a [&'a str],
286    theirs_lines: &'a [&'a str],
287) -> (usize, usize) {
288    // Common prefix
289    let prefix_len = ours_lines.iter()
290        .zip(theirs_lines.iter())
291        .take_while(|(a, b)| a == b)
292        .count();
293
294    // Common suffix (don't overlap with prefix)
295    let ours_remaining = ours_lines.len() - prefix_len;
296    let theirs_remaining = theirs_lines.len() - prefix_len;
297    let max_suffix = ours_remaining.min(theirs_remaining);
298    let suffix_len = ours_lines.iter().rev()
299        .zip(theirs_lines.iter().rev())
300        .take(max_suffix)
301        .take_while(|(a, b)| a == b)
302        .count();
303
304    (prefix_len, suffix_len)
305}
306
307impl EntityConflict {
308    /// Render this conflict as conflict markers.
309    ///
310    /// When `fmt.enhanced` is true, includes entity metadata and resolution hints.
311    /// When false, outputs standard git-compatible markers.
312    ///
313    /// Narrows conflict markers to only the differing lines: common prefix and
314    /// suffix lines are emitted as clean text outside the markers.
315    pub fn to_conflict_markers(&self, fmt: &MarkerFormat) -> String {
316        let ours = self.ours_content.as_deref().unwrap_or("");
317        let theirs = self.theirs_content.as_deref().unwrap_or("");
318        let open = "<".repeat(fmt.marker_length);
319        let sep = "=".repeat(fmt.marker_length);
320        let close = ">".repeat(fmt.marker_length);
321
322        let ours_lines: Vec<&str> = ours.lines().collect();
323        let theirs_lines: Vec<&str> = theirs.lines().collect();
324
325        let (prefix_len, suffix_len) = narrow_conflict_lines(&ours_lines, &theirs_lines);
326
327        // Only narrow if there's actually a common prefix or suffix to strip
328        let has_narrowing = prefix_len > 0 || suffix_len > 0;
329        let ours_mid = &ours_lines[prefix_len..ours_lines.len() - suffix_len];
330        let theirs_mid = &theirs_lines[prefix_len..theirs_lines.len() - suffix_len];
331
332        let mut out = String::new();
333
334        // Emit common prefix as clean text
335        if has_narrowing {
336            for line in &ours_lines[..prefix_len] {
337                out.push_str(line);
338                out.push('\n');
339            }
340        }
341
342        // Opening marker
343        if fmt.enhanced {
344            let confidence = match &self.complexity {
345                ConflictComplexity::Text => "high",
346                ConflictComplexity::Syntax => "medium",
347                ConflictComplexity::Functional => "medium",
348                ConflictComplexity::TextSyntax => "medium",
349                ConflictComplexity::TextFunctional => "medium",
350                ConflictComplexity::SyntaxFunctional => "low",
351                ConflictComplexity::TextSyntaxFunctional => "low",
352                ConflictComplexity::Unknown => "unknown",
353            };
354            let label = format!(
355                "{} `{}` ({}, confidence: {})",
356                self.entity_type, self.entity_name, self.complexity, confidence
357            );
358            let hint = self.complexity.resolution_hint();
359            out.push_str(&format!("{} ours \u{2014} {}\n", open, label));
360            out.push_str(&format!("// hint: {}\n", hint));
361        } else {
362            out.push_str(&format!("{} ours\n", open));
363        }
364
365        // Ours content (narrowed or full)
366        if has_narrowing {
367            for line in ours_mid {
368                out.push_str(line);
369                out.push('\n');
370            }
371        } else {
372            out.push_str(ours);
373            if !ours.is_empty() && !ours.ends_with('\n') {
374                out.push('\n');
375            }
376        }
377
378        // Base section for diff3 format (standard mode only)
379        if !fmt.enhanced {
380            let base_marker = "|".repeat(fmt.marker_length);
381            out.push_str(&format!("{} base\n", base_marker));
382            let base = self.base_content.as_deref().unwrap_or("");
383            if has_narrowing {
384                let base_lines: Vec<&str> = base.lines().collect();
385                // Use prefix/suffix from ours/theirs narrowing as approximation
386                let base_prefix = prefix_len.min(base_lines.len());
387                let base_suffix = suffix_len.min(base_lines.len().saturating_sub(base_prefix));
388                for line in &base_lines[base_prefix..base_lines.len() - base_suffix] {
389                    out.push_str(line);
390                    out.push('\n');
391                }
392            } else {
393                out.push_str(base);
394                if !base.is_empty() && !base.ends_with('\n') {
395                    out.push('\n');
396                }
397            }
398        }
399
400        out.push_str(&format!("{}\n", sep));
401
402        // Theirs content (narrowed or full)
403        if has_narrowing {
404            for line in theirs_mid {
405                out.push_str(line);
406                out.push('\n');
407            }
408        } else {
409            out.push_str(theirs);
410            if !theirs.is_empty() && !theirs.ends_with('\n') {
411                out.push('\n');
412            }
413        }
414
415        // Closing marker
416        if fmt.enhanced {
417            let confidence = match &self.complexity {
418                ConflictComplexity::Text => "high",
419                ConflictComplexity::Syntax => "medium",
420                ConflictComplexity::Functional => "medium",
421                ConflictComplexity::TextSyntax => "medium",
422                ConflictComplexity::TextFunctional => "medium",
423                ConflictComplexity::SyntaxFunctional => "low",
424                ConflictComplexity::TextSyntaxFunctional => "low",
425                ConflictComplexity::Unknown => "unknown",
426            };
427            let label = format!(
428                "{} `{}` ({}, confidence: {})",
429                self.entity_type, self.entity_name, self.complexity, confidence
430            );
431            out.push_str(&format!("{} theirs \u{2014} {}\n", close, label));
432        } else {
433            out.push_str(&format!("{} theirs\n", close));
434        }
435
436        // Emit common suffix as clean text
437        if has_narrowing {
438            for line in &ours_lines[ours_lines.len() - suffix_len..] {
439                out.push_str(line);
440                out.push('\n');
441            }
442        }
443
444        out
445    }
446}
447
448/// A parsed conflict extracted from weave-enhanced conflict markers.
449#[derive(Debug, Clone)]
450pub struct ParsedConflict {
451    pub entity_name: String,
452    pub entity_kind: String,
453    pub complexity: ConflictComplexity,
454    pub confidence: String,
455    pub hint: String,
456    pub ours_content: String,
457    pub theirs_content: String,
458}
459
460/// Parse weave-enhanced conflict markers from merged file content.
461///
462/// Returns a `Vec<ParsedConflict>` for each conflict block found.
463/// Expects markers in the format produced by `EntityConflict::to_conflict_markers()`.
464pub fn parse_weave_conflicts(content: &str) -> Vec<ParsedConflict> {
465    let mut conflicts = Vec::new();
466    let lines: Vec<&str> = content.lines().collect();
467    let mut i = 0;
468
469    while i < lines.len() {
470        // Look for <<<<<<< ours — <type> `<name>` (<complexity>, confidence: <conf>)
471        if lines[i].starts_with("<<<<<<< ours") {
472            let header = lines[i];
473            let (entity_kind, entity_name, complexity, confidence) = parse_conflict_header(header);
474
475            i += 1;
476
477            // Read hint line
478            let mut hint = String::new();
479            if i < lines.len() && lines[i].starts_with("// hint: ") {
480                hint = lines[i].trim_start_matches("// hint: ").to_string();
481                i += 1;
482            }
483
484            // Read ours content until =======
485            let mut ours_lines = Vec::new();
486            while i < lines.len() && lines[i] != "=======" {
487                ours_lines.push(lines[i]);
488                i += 1;
489            }
490            i += 1; // skip =======
491
492            // Read theirs content until >>>>>>>
493            let mut theirs_lines = Vec::new();
494            while i < lines.len() && !lines[i].starts_with(">>>>>>> theirs") {
495                theirs_lines.push(lines[i]);
496                i += 1;
497            }
498            i += 1; // skip >>>>>>>
499
500            let ours_content = if ours_lines.is_empty() {
501                String::new()
502            } else {
503                ours_lines.join("\n") + "\n"
504            };
505            let theirs_content = if theirs_lines.is_empty() {
506                String::new()
507            } else {
508                theirs_lines.join("\n") + "\n"
509            };
510
511            conflicts.push(ParsedConflict {
512                entity_name,
513                entity_kind,
514                complexity,
515                confidence,
516                hint,
517                ours_content,
518                theirs_content,
519            });
520        } else {
521            i += 1;
522        }
523    }
524
525    conflicts
526}
527
528fn parse_conflict_header(header: &str) -> (String, String, ConflictComplexity, String) {
529    // Format: "<<<<<<< ours — <type> `<name>` (<complexity>, confidence: <conf>)"
530    let after_dash = header
531        .split('\u{2014}')
532        .nth(1)
533        .unwrap_or(header)
534        .trim();
535
536    // Extract entity type (word before backtick)
537    let entity_kind = after_dash
538        .split('`')
539        .next()
540        .unwrap_or("")
541        .trim()
542        .to_string();
543
544    // Extract entity name (between backticks)
545    let entity_name = after_dash
546        .split('`')
547        .nth(1)
548        .unwrap_or("")
549        .to_string();
550
551    // Extract complexity and confidence from parenthesized section
552    let paren_content = after_dash
553        .rsplit('(')
554        .next()
555        .unwrap_or("")
556        .trim_end_matches(')');
557
558    let parts: Vec<&str> = paren_content.split(',').map(|s| s.trim()).collect();
559    let complexity = match parts.first().copied().unwrap_or("") {
560        "T" => ConflictComplexity::Text,
561        "S" => ConflictComplexity::Syntax,
562        "F" => ConflictComplexity::Functional,
563        "T+S" => ConflictComplexity::TextSyntax,
564        "T+F" => ConflictComplexity::TextFunctional,
565        "S+F" => ConflictComplexity::SyntaxFunctional,
566        "T+S+F" => ConflictComplexity::TextSyntaxFunctional,
567        _ => ConflictComplexity::Unknown,
568    };
569
570    let confidence = parts
571        .iter()
572        .find(|p| p.starts_with("confidence:"))
573        .map(|p| p.trim_start_matches("confidence:").trim().to_string())
574        .unwrap_or_else(|| "unknown".to_string());
575
576    (entity_kind, entity_name, complexity, confidence)
577}
578
579/// Statistics about a merge operation.
580#[derive(Debug, Clone, Default, Serialize)]
581pub struct MergeStats {
582    pub entities_unchanged: usize,
583    pub entities_ours_only: usize,
584    pub entities_theirs_only: usize,
585    pub entities_both_changed_merged: usize,
586    pub entities_conflicted: usize,
587    pub entities_added_ours: usize,
588    pub entities_added_theirs: usize,
589    pub entities_deleted: usize,
590    pub used_fallback: bool,
591    /// Entities that were auto-merged but reference other modified entities.
592    pub semantic_warnings: usize,
593    /// Entities resolved via diffy 3-way merge (medium confidence).
594    pub resolved_via_diffy: usize,
595    /// Entities resolved via inner entity merge (high confidence).
596    pub resolved_via_inner_merge: usize,
597}
598
599impl MergeStats {
600    pub fn has_conflicts(&self) -> bool {
601        self.entities_conflicted > 0
602    }
603
604    /// Overall merge confidence: High (only one side changed), Medium (diffy resolved),
605    /// Low (inner entity merge or fallback), or Conflict.
606    pub fn confidence(&self) -> &'static str {
607        if self.entities_conflicted > 0 {
608            "conflict"
609        } else if self.resolved_via_inner_merge > 0 || self.used_fallback {
610            "medium"
611        } else if self.resolved_via_diffy > 0 {
612            "high"
613        } else {
614            "very_high"
615        }
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[test]
624    fn test_classify_functional_conflict() {
625        let base = "function foo() {\n    return 1;\n}\n";
626        let ours = "function foo() {\n    return 2;\n}\n";
627        let theirs = "function foo() {\n    return 3;\n}\n";
628        assert_eq!(
629            classify_conflict(Some(base), Some(ours), Some(theirs)),
630            ConflictComplexity::Functional
631        );
632    }
633
634    #[test]
635    fn test_classify_syntax_conflict() {
636        // Signature changed, body unchanged
637        let base = "function foo(a: number) {\n    return a;\n}\n";
638        let ours = "function foo(a: string) {\n    return a;\n}\n";
639        let theirs = "function foo(a: boolean) {\n    return a;\n}\n";
640        assert_eq!(
641            classify_conflict(Some(base), Some(ours), Some(theirs)),
642            ConflictComplexity::Syntax
643        );
644    }
645
646    #[test]
647    fn test_classify_text_conflict() {
648        // Only comment changes
649        let base = "// old comment\n    return 1;\n";
650        let ours = "// ours comment\n    return 1;\n";
651        let theirs = "// theirs comment\n    return 1;\n";
652        assert_eq!(
653            classify_conflict(Some(base), Some(ours), Some(theirs)),
654            ConflictComplexity::Text
655        );
656    }
657
658    #[test]
659    fn test_classify_syntax_functional_conflict() {
660        // Signature + body changed
661        let base = "function foo(a: number) {\n    return a;\n}\n";
662        let ours = "function foo(a: string) {\n    return a + 1;\n}\n";
663        let theirs = "function foo(a: boolean) {\n    return a + 2;\n}\n";
664        assert_eq!(
665            classify_conflict(Some(base), Some(ours), Some(theirs)),
666            ConflictComplexity::SyntaxFunctional
667        );
668    }
669
670    #[test]
671    fn test_classify_unknown_when_identical() {
672        let content = "function foo() {\n    return 1;\n}\n";
673        assert_eq!(
674            classify_conflict(Some(content), Some(content), Some(content)),
675            ConflictComplexity::Unknown
676        );
677    }
678
679    #[test]
680    fn test_classify_modify_delete() {
681        // Theirs deleted (None), ours modified body
682        // vs empty: both signature and body differ → SyntaxFunctional
683        let base = "function foo() {\n    return 1;\n}\n";
684        let ours = "function foo() {\n    return 2;\n}\n";
685        assert_eq!(
686            classify_conflict(Some(base), Some(ours), None),
687            ConflictComplexity::SyntaxFunctional
688        );
689    }
690
691    #[test]
692    fn test_classify_both_added() {
693        // No base → comparing each side against empty
694        // Both signature and body differ from empty → SyntaxFunctional
695        let ours = "function foo() {\n    return 1;\n}\n";
696        let theirs = "function foo() {\n    return 2;\n}\n";
697        assert_eq!(
698            classify_conflict(None, Some(ours), Some(theirs)),
699            ConflictComplexity::SyntaxFunctional
700        );
701    }
702
703    #[test]
704    fn test_conflict_markers_include_complexity_and_hint() {
705        let conflict = EntityConflict {
706            entity_name: "foo".to_string(),
707            entity_type: "function".to_string(),
708            kind: ConflictKind::BothModified,
709            complexity: ConflictComplexity::Functional,
710            ours_content: Some("return 1;".to_string()),
711            theirs_content: Some("return 2;".to_string()),
712            base_content: Some("return 0;".to_string()),
713        };
714        let markers = conflict.to_conflict_markers(&MarkerFormat::default());
715        assert!(markers.contains("confidence: medium"), "Markers should contain confidence: {}", markers);
716        assert!(markers.contains("// hint: Logic changed on both sides"), "Markers should contain hint: {}", markers);
717    }
718
719    #[test]
720    fn test_resolution_hints() {
721        assert!(ConflictComplexity::Text.resolution_hint().contains("Cosmetic"));
722        assert!(ConflictComplexity::Syntax.resolution_hint().contains("Structural"));
723        assert!(ConflictComplexity::Functional.resolution_hint().contains("Logic"));
724        assert!(ConflictComplexity::TextSyntax.resolution_hint().contains("Renamed"));
725        assert!(ConflictComplexity::TextFunctional.resolution_hint().contains("Logic and cosmetic"));
726        assert!(ConflictComplexity::SyntaxFunctional.resolution_hint().contains("Structural and logic"));
727        assert!(ConflictComplexity::TextSyntaxFunctional.resolution_hint().contains("All three"));
728        assert!(ConflictComplexity::Unknown.resolution_hint().contains("Could not classify"));
729    }
730
731    #[test]
732    fn test_parse_weave_conflicts() {
733        let conflict = EntityConflict {
734            entity_name: "process".to_string(),
735            entity_type: "function".to_string(),
736            kind: ConflictKind::BothModified,
737            complexity: ConflictComplexity::Functional,
738            ours_content: Some("fn process() { return 1; }".to_string()),
739            theirs_content: Some("fn process() { return 2; }".to_string()),
740            base_content: Some("fn process() { return 0; }".to_string()),
741        };
742        let markers = conflict.to_conflict_markers(&MarkerFormat::default());
743
744        let parsed = parse_weave_conflicts(&markers);
745        assert_eq!(parsed.len(), 1);
746        assert_eq!(parsed[0].entity_name, "process");
747        assert_eq!(parsed[0].entity_kind, "function");
748        assert_eq!(parsed[0].complexity, ConflictComplexity::Functional);
749        assert_eq!(parsed[0].confidence, "medium");
750        assert!(parsed[0].hint.contains("Logic changed"));
751        assert!(parsed[0].ours_content.contains("return 1"));
752        assert!(parsed[0].theirs_content.contains("return 2"));
753    }
754
755    #[test]
756    fn test_parse_weave_conflicts_multiple() {
757        let c1 = EntityConflict {
758            entity_name: "foo".to_string(),
759            entity_type: "function".to_string(),
760            kind: ConflictKind::BothModified,
761            complexity: ConflictComplexity::Text,
762            ours_content: Some("// a".to_string()),
763            theirs_content: Some("// b".to_string()),
764            base_content: None,
765        };
766        let c2 = EntityConflict {
767            entity_name: "Bar".to_string(),
768            entity_type: "class".to_string(),
769            kind: ConflictKind::BothModified,
770            complexity: ConflictComplexity::SyntaxFunctional,
771            ours_content: Some("class Bar { x() {} }".to_string()),
772            theirs_content: Some("class Bar { y() {} }".to_string()),
773            base_content: None,
774        };
775        let content = format!("some code\n{}\nmore code\n{}\nend", c1.to_conflict_markers(&MarkerFormat::default()), c2.to_conflict_markers(&MarkerFormat::default()));
776        let parsed = parse_weave_conflicts(&content);
777        assert_eq!(parsed.len(), 2);
778        assert_eq!(parsed[0].entity_name, "foo");
779        assert_eq!(parsed[0].complexity, ConflictComplexity::Text);
780        assert_eq!(parsed[1].entity_name, "Bar");
781        assert_eq!(parsed[1].complexity, ConflictComplexity::SyntaxFunctional);
782    }
783
784    #[test]
785    fn test_standard_markers_no_metadata() {
786        let conflict = EntityConflict {
787            entity_name: "foo".to_string(),
788            entity_type: "function".to_string(),
789            kind: ConflictKind::BothModified,
790            complexity: ConflictComplexity::Functional,
791            ours_content: Some("return 1;".to_string()),
792            theirs_content: Some("return 2;".to_string()),
793            base_content: Some("return 0;".to_string()),
794        };
795        let markers = conflict.to_conflict_markers(&MarkerFormat::standard(7));
796        assert_eq!(markers, "<<<<<<< ours\nreturn 1;\n||||||| base\nreturn 0;\n=======\nreturn 2;\n>>>>>>> theirs\n");
797        // No em-dash, no hint, no metadata
798        assert!(!markers.contains('\u{2014}'));
799        assert!(!markers.contains("hint"));
800        assert!(!markers.contains("confidence"));
801    }
802
803    #[test]
804    fn test_standard_markers_custom_length() {
805        let conflict = EntityConflict {
806            entity_name: "foo".to_string(),
807            entity_type: "function".to_string(),
808            kind: ConflictKind::BothModified,
809            complexity: ConflictComplexity::Functional,
810            ours_content: Some("a".to_string()),
811            theirs_content: Some("b".to_string()),
812            base_content: None,
813        };
814        let markers = conflict.to_conflict_markers(&MarkerFormat::standard(11));
815        assert!(markers.starts_with("<<<<<<<<<<<")); // 11 <'s
816        assert!(markers.contains("===========")); // 11 ='s
817        assert!(markers.contains(">>>>>>>>>>>")); // 11 >'s
818    }
819}
820
821impl fmt::Display for MergeStats {
822    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
823        write!(f, "unchanged: {}", self.entities_unchanged)?;
824        if self.entities_ours_only > 0 {
825            write!(f, ", ours-only: {}", self.entities_ours_only)?;
826        }
827        if self.entities_theirs_only > 0 {
828            write!(f, ", theirs-only: {}", self.entities_theirs_only)?;
829        }
830        if self.entities_both_changed_merged > 0 {
831            write!(f, ", auto-merged: {}", self.entities_both_changed_merged)?;
832        }
833        if self.entities_added_ours > 0 {
834            write!(f, ", added-ours: {}", self.entities_added_ours)?;
835        }
836        if self.entities_added_theirs > 0 {
837            write!(f, ", added-theirs: {}", self.entities_added_theirs)?;
838        }
839        if self.entities_deleted > 0 {
840            write!(f, ", deleted: {}", self.entities_deleted)?;
841        }
842        if self.entities_conflicted > 0 {
843            write!(f, ", CONFLICTS: {}", self.entities_conflicted)?;
844        }
845        if self.semantic_warnings > 0 {
846            write!(f, ", semantic-warnings: {}", self.semantic_warnings)?;
847        }
848        if self.used_fallback {
849            write!(f, " (line-level fallback)")?;
850        }
851        Ok(())
852    }
853}