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