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        out.push_str(&format!("{}\n", sep));
379
380        // Theirs content (narrowed or full)
381        if has_narrowing {
382            for line in theirs_mid {
383                out.push_str(line);
384                out.push('\n');
385            }
386        } else {
387            out.push_str(theirs);
388            if !theirs.is_empty() && !theirs.ends_with('\n') {
389                out.push('\n');
390            }
391        }
392
393        // Closing marker
394        if fmt.enhanced {
395            let confidence = match &self.complexity {
396                ConflictComplexity::Text => "high",
397                ConflictComplexity::Syntax => "medium",
398                ConflictComplexity::Functional => "medium",
399                ConflictComplexity::TextSyntax => "medium",
400                ConflictComplexity::TextFunctional => "medium",
401                ConflictComplexity::SyntaxFunctional => "low",
402                ConflictComplexity::TextSyntaxFunctional => "low",
403                ConflictComplexity::Unknown => "unknown",
404            };
405            let label = format!(
406                "{} `{}` ({}, confidence: {})",
407                self.entity_type, self.entity_name, self.complexity, confidence
408            );
409            out.push_str(&format!("{} theirs \u{2014} {}\n", close, label));
410        } else {
411            out.push_str(&format!("{} theirs\n", close));
412        }
413
414        // Emit common suffix as clean text
415        if has_narrowing {
416            for line in &ours_lines[ours_lines.len() - suffix_len..] {
417                out.push_str(line);
418                out.push('\n');
419            }
420        }
421
422        out
423    }
424}
425
426/// A parsed conflict extracted from weave-enhanced conflict markers.
427#[derive(Debug, Clone)]
428pub struct ParsedConflict {
429    pub entity_name: String,
430    pub entity_kind: String,
431    pub complexity: ConflictComplexity,
432    pub confidence: String,
433    pub hint: String,
434    pub ours_content: String,
435    pub theirs_content: String,
436}
437
438/// Parse weave-enhanced conflict markers from merged file content.
439///
440/// Returns a `Vec<ParsedConflict>` for each conflict block found.
441/// Expects markers in the format produced by `EntityConflict::to_conflict_markers()`.
442pub fn parse_weave_conflicts(content: &str) -> Vec<ParsedConflict> {
443    let mut conflicts = Vec::new();
444    let lines: Vec<&str> = content.lines().collect();
445    let mut i = 0;
446
447    while i < lines.len() {
448        // Look for <<<<<<< ours — <type> `<name>` (<complexity>, confidence: <conf>)
449        if lines[i].starts_with("<<<<<<< ours") {
450            let header = lines[i];
451            let (entity_kind, entity_name, complexity, confidence) = parse_conflict_header(header);
452
453            i += 1;
454
455            // Read hint line
456            let mut hint = String::new();
457            if i < lines.len() && lines[i].starts_with("// hint: ") {
458                hint = lines[i].trim_start_matches("// hint: ").to_string();
459                i += 1;
460            }
461
462            // Read ours content until =======
463            let mut ours_lines = Vec::new();
464            while i < lines.len() && lines[i] != "=======" {
465                ours_lines.push(lines[i]);
466                i += 1;
467            }
468            i += 1; // skip =======
469
470            // Read theirs content until >>>>>>>
471            let mut theirs_lines = Vec::new();
472            while i < lines.len() && !lines[i].starts_with(">>>>>>> theirs") {
473                theirs_lines.push(lines[i]);
474                i += 1;
475            }
476            i += 1; // skip >>>>>>>
477
478            let ours_content = if ours_lines.is_empty() {
479                String::new()
480            } else {
481                ours_lines.join("\n") + "\n"
482            };
483            let theirs_content = if theirs_lines.is_empty() {
484                String::new()
485            } else {
486                theirs_lines.join("\n") + "\n"
487            };
488
489            conflicts.push(ParsedConflict {
490                entity_name,
491                entity_kind,
492                complexity,
493                confidence,
494                hint,
495                ours_content,
496                theirs_content,
497            });
498        } else {
499            i += 1;
500        }
501    }
502
503    conflicts
504}
505
506fn parse_conflict_header(header: &str) -> (String, String, ConflictComplexity, String) {
507    // Format: "<<<<<<< ours — <type> `<name>` (<complexity>, confidence: <conf>)"
508    let after_dash = header
509        .split('\u{2014}')
510        .nth(1)
511        .unwrap_or(header)
512        .trim();
513
514    // Extract entity type (word before backtick)
515    let entity_kind = after_dash
516        .split('`')
517        .next()
518        .unwrap_or("")
519        .trim()
520        .to_string();
521
522    // Extract entity name (between backticks)
523    let entity_name = after_dash
524        .split('`')
525        .nth(1)
526        .unwrap_or("")
527        .to_string();
528
529    // Extract complexity and confidence from parenthesized section
530    let paren_content = after_dash
531        .rsplit('(')
532        .next()
533        .unwrap_or("")
534        .trim_end_matches(')');
535
536    let parts: Vec<&str> = paren_content.split(',').map(|s| s.trim()).collect();
537    let complexity = match parts.first().copied().unwrap_or("") {
538        "T" => ConflictComplexity::Text,
539        "S" => ConflictComplexity::Syntax,
540        "F" => ConflictComplexity::Functional,
541        "T+S" => ConflictComplexity::TextSyntax,
542        "T+F" => ConflictComplexity::TextFunctional,
543        "S+F" => ConflictComplexity::SyntaxFunctional,
544        "T+S+F" => ConflictComplexity::TextSyntaxFunctional,
545        _ => ConflictComplexity::Unknown,
546    };
547
548    let confidence = parts
549        .iter()
550        .find(|p| p.starts_with("confidence:"))
551        .map(|p| p.trim_start_matches("confidence:").trim().to_string())
552        .unwrap_or_else(|| "unknown".to_string());
553
554    (entity_kind, entity_name, complexity, confidence)
555}
556
557/// Statistics about a merge operation.
558#[derive(Debug, Clone, Default, Serialize)]
559pub struct MergeStats {
560    pub entities_unchanged: usize,
561    pub entities_ours_only: usize,
562    pub entities_theirs_only: usize,
563    pub entities_both_changed_merged: usize,
564    pub entities_conflicted: usize,
565    pub entities_added_ours: usize,
566    pub entities_added_theirs: usize,
567    pub entities_deleted: usize,
568    pub used_fallback: bool,
569    /// Entities that were auto-merged but reference other modified entities.
570    pub semantic_warnings: usize,
571    /// Entities resolved via diffy 3-way merge (medium confidence).
572    pub resolved_via_diffy: usize,
573    /// Entities resolved via inner entity merge (high confidence).
574    pub resolved_via_inner_merge: usize,
575}
576
577impl MergeStats {
578    pub fn has_conflicts(&self) -> bool {
579        self.entities_conflicted > 0
580    }
581
582    /// Overall merge confidence: High (only one side changed), Medium (diffy resolved),
583    /// Low (inner entity merge or fallback), or Conflict.
584    pub fn confidence(&self) -> &'static str {
585        if self.entities_conflicted > 0 {
586            "conflict"
587        } else if self.resolved_via_inner_merge > 0 || self.used_fallback {
588            "medium"
589        } else if self.resolved_via_diffy > 0 {
590            "high"
591        } else {
592            "very_high"
593        }
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn test_classify_functional_conflict() {
603        let base = "function foo() {\n    return 1;\n}\n";
604        let ours = "function foo() {\n    return 2;\n}\n";
605        let theirs = "function foo() {\n    return 3;\n}\n";
606        assert_eq!(
607            classify_conflict(Some(base), Some(ours), Some(theirs)),
608            ConflictComplexity::Functional
609        );
610    }
611
612    #[test]
613    fn test_classify_syntax_conflict() {
614        // Signature changed, body unchanged
615        let base = "function foo(a: number) {\n    return a;\n}\n";
616        let ours = "function foo(a: string) {\n    return a;\n}\n";
617        let theirs = "function foo(a: boolean) {\n    return a;\n}\n";
618        assert_eq!(
619            classify_conflict(Some(base), Some(ours), Some(theirs)),
620            ConflictComplexity::Syntax
621        );
622    }
623
624    #[test]
625    fn test_classify_text_conflict() {
626        // Only comment changes
627        let base = "// old comment\n    return 1;\n";
628        let ours = "// ours comment\n    return 1;\n";
629        let theirs = "// theirs comment\n    return 1;\n";
630        assert_eq!(
631            classify_conflict(Some(base), Some(ours), Some(theirs)),
632            ConflictComplexity::Text
633        );
634    }
635
636    #[test]
637    fn test_classify_syntax_functional_conflict() {
638        // Signature + body changed
639        let base = "function foo(a: number) {\n    return a;\n}\n";
640        let ours = "function foo(a: string) {\n    return a + 1;\n}\n";
641        let theirs = "function foo(a: boolean) {\n    return a + 2;\n}\n";
642        assert_eq!(
643            classify_conflict(Some(base), Some(ours), Some(theirs)),
644            ConflictComplexity::SyntaxFunctional
645        );
646    }
647
648    #[test]
649    fn test_classify_unknown_when_identical() {
650        let content = "function foo() {\n    return 1;\n}\n";
651        assert_eq!(
652            classify_conflict(Some(content), Some(content), Some(content)),
653            ConflictComplexity::Unknown
654        );
655    }
656
657    #[test]
658    fn test_classify_modify_delete() {
659        // Theirs deleted (None), ours modified body
660        // vs empty: both signature and body differ → SyntaxFunctional
661        let base = "function foo() {\n    return 1;\n}\n";
662        let ours = "function foo() {\n    return 2;\n}\n";
663        assert_eq!(
664            classify_conflict(Some(base), Some(ours), None),
665            ConflictComplexity::SyntaxFunctional
666        );
667    }
668
669    #[test]
670    fn test_classify_both_added() {
671        // No base → comparing each side against empty
672        // Both signature and body differ from empty → SyntaxFunctional
673        let ours = "function foo() {\n    return 1;\n}\n";
674        let theirs = "function foo() {\n    return 2;\n}\n";
675        assert_eq!(
676            classify_conflict(None, Some(ours), Some(theirs)),
677            ConflictComplexity::SyntaxFunctional
678        );
679    }
680
681    #[test]
682    fn test_conflict_markers_include_complexity_and_hint() {
683        let conflict = EntityConflict {
684            entity_name: "foo".to_string(),
685            entity_type: "function".to_string(),
686            kind: ConflictKind::BothModified,
687            complexity: ConflictComplexity::Functional,
688            ours_content: Some("return 1;".to_string()),
689            theirs_content: Some("return 2;".to_string()),
690            base_content: Some("return 0;".to_string()),
691        };
692        let markers = conflict.to_conflict_markers(&MarkerFormat::default());
693        assert!(markers.contains("confidence: medium"), "Markers should contain confidence: {}", markers);
694        assert!(markers.contains("// hint: Logic changed on both sides"), "Markers should contain hint: {}", markers);
695    }
696
697    #[test]
698    fn test_resolution_hints() {
699        assert!(ConflictComplexity::Text.resolution_hint().contains("Cosmetic"));
700        assert!(ConflictComplexity::Syntax.resolution_hint().contains("Structural"));
701        assert!(ConflictComplexity::Functional.resolution_hint().contains("Logic"));
702        assert!(ConflictComplexity::TextSyntax.resolution_hint().contains("Renamed"));
703        assert!(ConflictComplexity::TextFunctional.resolution_hint().contains("Logic and cosmetic"));
704        assert!(ConflictComplexity::SyntaxFunctional.resolution_hint().contains("Structural and logic"));
705        assert!(ConflictComplexity::TextSyntaxFunctional.resolution_hint().contains("All three"));
706        assert!(ConflictComplexity::Unknown.resolution_hint().contains("Could not classify"));
707    }
708
709    #[test]
710    fn test_parse_weave_conflicts() {
711        let conflict = EntityConflict {
712            entity_name: "process".to_string(),
713            entity_type: "function".to_string(),
714            kind: ConflictKind::BothModified,
715            complexity: ConflictComplexity::Functional,
716            ours_content: Some("fn process() { return 1; }".to_string()),
717            theirs_content: Some("fn process() { return 2; }".to_string()),
718            base_content: Some("fn process() { return 0; }".to_string()),
719        };
720        let markers = conflict.to_conflict_markers(&MarkerFormat::default());
721
722        let parsed = parse_weave_conflicts(&markers);
723        assert_eq!(parsed.len(), 1);
724        assert_eq!(parsed[0].entity_name, "process");
725        assert_eq!(parsed[0].entity_kind, "function");
726        assert_eq!(parsed[0].complexity, ConflictComplexity::Functional);
727        assert_eq!(parsed[0].confidence, "medium");
728        assert!(parsed[0].hint.contains("Logic changed"));
729        assert!(parsed[0].ours_content.contains("return 1"));
730        assert!(parsed[0].theirs_content.contains("return 2"));
731    }
732
733    #[test]
734    fn test_parse_weave_conflicts_multiple() {
735        let c1 = EntityConflict {
736            entity_name: "foo".to_string(),
737            entity_type: "function".to_string(),
738            kind: ConflictKind::BothModified,
739            complexity: ConflictComplexity::Text,
740            ours_content: Some("// a".to_string()),
741            theirs_content: Some("// b".to_string()),
742            base_content: None,
743        };
744        let c2 = EntityConflict {
745            entity_name: "Bar".to_string(),
746            entity_type: "class".to_string(),
747            kind: ConflictKind::BothModified,
748            complexity: ConflictComplexity::SyntaxFunctional,
749            ours_content: Some("class Bar { x() {} }".to_string()),
750            theirs_content: Some("class Bar { y() {} }".to_string()),
751            base_content: None,
752        };
753        let content = format!("some code\n{}\nmore code\n{}\nend", c1.to_conflict_markers(&MarkerFormat::default()), c2.to_conflict_markers(&MarkerFormat::default()));
754        let parsed = parse_weave_conflicts(&content);
755        assert_eq!(parsed.len(), 2);
756        assert_eq!(parsed[0].entity_name, "foo");
757        assert_eq!(parsed[0].complexity, ConflictComplexity::Text);
758        assert_eq!(parsed[1].entity_name, "Bar");
759        assert_eq!(parsed[1].complexity, ConflictComplexity::SyntaxFunctional);
760    }
761
762    #[test]
763    fn test_standard_markers_no_metadata() {
764        let conflict = EntityConflict {
765            entity_name: "foo".to_string(),
766            entity_type: "function".to_string(),
767            kind: ConflictKind::BothModified,
768            complexity: ConflictComplexity::Functional,
769            ours_content: Some("return 1;".to_string()),
770            theirs_content: Some("return 2;".to_string()),
771            base_content: Some("return 0;".to_string()),
772        };
773        let markers = conflict.to_conflict_markers(&MarkerFormat::standard(7));
774        assert_eq!(markers, "<<<<<<< ours\nreturn 1;\n=======\nreturn 2;\n>>>>>>> theirs\n");
775        // No em-dash, no hint, no metadata
776        assert!(!markers.contains('\u{2014}'));
777        assert!(!markers.contains("hint"));
778        assert!(!markers.contains("confidence"));
779    }
780
781    #[test]
782    fn test_standard_markers_custom_length() {
783        let conflict = EntityConflict {
784            entity_name: "foo".to_string(),
785            entity_type: "function".to_string(),
786            kind: ConflictKind::BothModified,
787            complexity: ConflictComplexity::Functional,
788            ours_content: Some("a".to_string()),
789            theirs_content: Some("b".to_string()),
790            base_content: None,
791        };
792        let markers = conflict.to_conflict_markers(&MarkerFormat::standard(11));
793        assert!(markers.starts_with("<<<<<<<<<<<")); // 11 <'s
794        assert!(markers.contains("===========")); // 11 ='s
795        assert!(markers.contains(">>>>>>>>>>>")); // 11 >'s
796    }
797}
798
799impl fmt::Display for MergeStats {
800    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
801        write!(f, "unchanged: {}", self.entities_unchanged)?;
802        if self.entities_ours_only > 0 {
803            write!(f, ", ours-only: {}", self.entities_ours_only)?;
804        }
805        if self.entities_theirs_only > 0 {
806            write!(f, ", theirs-only: {}", self.entities_theirs_only)?;
807        }
808        if self.entities_both_changed_merged > 0 {
809            write!(f, ", auto-merged: {}", self.entities_both_changed_merged)?;
810        }
811        if self.entities_added_ours > 0 {
812            write!(f, ", added-ours: {}", self.entities_added_ours)?;
813        }
814        if self.entities_added_theirs > 0 {
815            write!(f, ", added-theirs: {}", self.entities_added_theirs)?;
816        }
817        if self.entities_deleted > 0 {
818            write!(f, ", deleted: {}", self.entities_deleted)?;
819        }
820        if self.entities_conflicted > 0 {
821            write!(f, ", CONFLICTS: {}", self.entities_conflicted)?;
822        }
823        if self.semantic_warnings > 0 {
824            write!(f, ", semantic-warnings: {}", self.semantic_warnings)?;
825        }
826        if self.used_fallback {
827            write!(f, " (line-level fallback)")?;
828        }
829        Ok(())
830    }
831}