cascade_cli/git/
conflict_analysis.rs

1use crate::errors::{CascadeError, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// Type of conflict detected
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
7pub enum ConflictType {
8    /// Only whitespace or formatting differences
9    Whitespace,
10    /// Only line ending differences (CRLF vs LF)
11    LineEnding,
12    /// Both sides added lines without overlapping changes
13    PureAddition,
14    /// Import/dependency statements that can be merged
15    ImportMerge,
16    /// Code structure changes (functions, classes, etc.)
17    Structural,
18    /// Content changes that overlap
19    ContentOverlap,
20    /// Complex conflicts requiring manual resolution
21    Complex,
22}
23
24/// Difficulty level for resolving a conflict
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub enum ConflictDifficulty {
27    /// Can be automatically resolved
28    Easy,
29    /// Requires simple user input
30    Medium,
31    /// Requires careful manual resolution
32    Hard,
33}
34
35/// Strategy for resolving a conflict
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub enum ResolutionStrategy {
38    /// Use "our" version
39    TakeOurs,
40    /// Use "their" version
41    TakeTheirs,
42    /// Merge both versions
43    Merge,
44    /// Apply custom resolution logic
45    Custom(String),
46    /// Cannot be automatically resolved
47    Manual,
48}
49
50/// Represents a conflict region in a file
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ConflictRegion {
53    /// File path where conflict occurs
54    pub file_path: String,
55    /// Byte position where conflict starts
56    pub start_pos: usize,
57    /// Byte position where conflict ends
58    pub end_pos: usize,
59    /// Line number where conflict starts
60    pub start_line: usize,
61    /// Line number where conflict ends
62    pub end_line: usize,
63    /// Content from "our" side (before separator)
64    pub our_content: String,
65    /// Content from "their" side (after separator)
66    pub their_content: String,
67    /// Type of conflict detected
68    pub conflict_type: ConflictType,
69    /// Difficulty level for resolution
70    pub difficulty: ConflictDifficulty,
71    /// Suggested resolution strategy
72    pub suggested_strategy: ResolutionStrategy,
73    /// Additional context or explanation
74    pub context: String,
75}
76
77/// Analysis result for a file with conflicts
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct FileConflictAnalysis {
80    /// Path to the file
81    pub file_path: String,
82    /// List of conflict regions in the file
83    pub conflicts: Vec<ConflictRegion>,
84    /// Overall difficulty assessment
85    pub overall_difficulty: ConflictDifficulty,
86    /// Whether all conflicts can be auto-resolved
87    pub auto_resolvable: bool,
88    /// Summary of conflict types
89    pub conflict_summary: HashMap<ConflictType, usize>,
90}
91
92/// Complete analysis of all conflicts in a rebase/merge operation
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ConflictAnalysis {
95    /// Analysis for each conflicted file
96    pub files: Vec<FileConflictAnalysis>,
97    /// Total number of conflicts
98    pub total_conflicts: usize,
99    /// Number of auto-resolvable conflicts
100    pub auto_resolvable_count: usize,
101    /// Files requiring manual resolution
102    pub manual_resolution_files: Vec<String>,
103    /// Recommended next steps
104    pub recommendations: Vec<String>,
105}
106
107/// Analyzes conflicts in files and provides resolution guidance
108pub struct ConflictAnalyzer {
109    /// Known patterns for different file types
110    file_patterns: HashMap<String, Vec<String>>,
111}
112
113impl ConflictAnalyzer {
114    /// Create a new conflict analyzer
115    pub fn new() -> Self {
116        let mut file_patterns = HashMap::new();
117
118        // Rust patterns (only imports/declarations, not function definitions)
119        file_patterns.insert(
120            "rs".to_string(),
121            vec!["use ".to_string(), "extern crate ".to_string()],
122        );
123
124        // Python patterns (only imports)
125        file_patterns.insert(
126            "py".to_string(),
127            vec!["import ".to_string(), "from ".to_string()],
128        );
129
130        // JavaScript/TypeScript patterns
131        file_patterns.insert(
132            "js".to_string(),
133            vec!["import ".to_string(), "export ".to_string()],
134        );
135
136        file_patterns.insert(
137            "ts".to_string(),
138            vec![
139                "import ".to_string(),
140                "export ".to_string(),
141                "interface ".to_string(),
142                "type ".to_string(),
143            ],
144        );
145
146        Self { file_patterns }
147    }
148
149    /// Analyze all conflicts in a file
150    pub fn analyze_file(&self, file_path: &str, content: &str) -> Result<FileConflictAnalysis> {
151        let conflicts = self.parse_conflict_markers(file_path, content)?;
152
153        let mut conflict_summary = HashMap::new();
154        let mut auto_resolvable_count = 0;
155
156        for conflict in &conflicts {
157            *conflict_summary
158                .entry(conflict.conflict_type.clone())
159                .or_insert(0) += 1;
160
161            if conflict.difficulty == ConflictDifficulty::Easy {
162                auto_resolvable_count += 1;
163            }
164        }
165
166        let overall_difficulty = self.assess_overall_difficulty(&conflicts);
167        let auto_resolvable = auto_resolvable_count == conflicts.len();
168
169        Ok(FileConflictAnalysis {
170            file_path: file_path.to_string(),
171            conflicts,
172            overall_difficulty,
173            auto_resolvable,
174            conflict_summary,
175        })
176    }
177
178    /// Analyze conflicts across multiple files
179    pub fn analyze_conflicts(
180        &self,
181        conflicted_files: &[String],
182        repo_path: &std::path::Path,
183    ) -> Result<ConflictAnalysis> {
184        let mut file_analyses = Vec::new();
185        let mut total_conflicts = 0;
186        let mut auto_resolvable_count = 0;
187        let mut manual_resolution_files = Vec::new();
188
189        for file_path in conflicted_files {
190            let full_path = repo_path.join(file_path);
191            let content = std::fs::read_to_string(&full_path)
192                .map_err(|e| CascadeError::config(format!("Failed to read {file_path}: {e}")))?;
193
194            let analysis = self.analyze_file(file_path, &content)?;
195
196            total_conflicts += analysis.conflicts.len();
197            auto_resolvable_count += analysis
198                .conflicts
199                .iter()
200                .filter(|c| c.difficulty == ConflictDifficulty::Easy)
201                .count();
202
203            if !analysis.auto_resolvable {
204                manual_resolution_files.push(file_path.clone());
205            }
206
207            file_analyses.push(analysis);
208        }
209
210        let recommendations = self.generate_recommendations(&file_analyses);
211
212        Ok(ConflictAnalysis {
213            files: file_analyses,
214            total_conflicts,
215            auto_resolvable_count,
216            manual_resolution_files,
217            recommendations,
218        })
219    }
220
221    /// Parse conflict markers from file content
222    fn parse_conflict_markers(
223        &self,
224        file_path: &str,
225        content: &str,
226    ) -> Result<Vec<ConflictRegion>> {
227        let lines: Vec<&str> = content.lines().collect();
228        let mut conflicts = Vec::new();
229        let mut i = 0;
230
231        while i < lines.len() {
232            if lines[i].starts_with("<<<<<<<") {
233                // Found start of conflict
234                let start_line = i + 1;
235                let mut separator_line = None;
236                let mut end_line = None;
237
238                // Find the separator and end
239                for (j, line) in lines.iter().enumerate().skip(i + 1) {
240                    if line.starts_with("=======") {
241                        separator_line = Some(j);
242                    } else if line.starts_with(">>>>>>>") {
243                        end_line = Some(j);
244                        break;
245                    }
246                }
247
248                if let (Some(sep), Some(end)) = (separator_line, end_line) {
249                    // Calculate byte positions
250                    let start_pos = lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
251                    let end_pos = lines[..=end].iter().map(|l| l.len() + 1).sum::<usize>();
252
253                    let our_content = lines[(i + 1)..sep].join("\n");
254                    let their_content = lines[(sep + 1)..end].join("\n");
255
256                    // Analyze this conflict
257                    let conflict_region = self.analyze_conflict_region(
258                        file_path,
259                        start_pos,
260                        end_pos,
261                        start_line,
262                        end + 1,
263                        &our_content,
264                        &their_content,
265                    )?;
266
267                    conflicts.push(conflict_region);
268                    i = end;
269                } else {
270                    i += 1;
271                }
272            } else {
273                i += 1;
274            }
275        }
276
277        Ok(conflicts)
278    }
279
280    /// Analyze a single conflict region
281    #[allow(clippy::too_many_arguments)]
282    fn analyze_conflict_region(
283        &self,
284        file_path: &str,
285        start_pos: usize,
286        end_pos: usize,
287        start_line: usize,
288        end_line: usize,
289        our_content: &str,
290        their_content: &str,
291    ) -> Result<ConflictRegion> {
292        let conflict_type = self.classify_conflict_type(file_path, our_content, their_content);
293        let difficulty = self.assess_difficulty(&conflict_type, our_content, their_content);
294        let suggested_strategy =
295            self.suggest_resolution_strategy(&conflict_type, our_content, their_content);
296        let context = self.generate_context(&conflict_type, our_content, their_content);
297
298        Ok(ConflictRegion {
299            file_path: file_path.to_string(),
300            start_pos,
301            end_pos,
302            start_line,
303            end_line,
304            our_content: our_content.to_string(),
305            their_content: their_content.to_string(),
306            conflict_type,
307            difficulty,
308            suggested_strategy,
309            context,
310        })
311    }
312
313    /// Classify the type of conflict
314    fn classify_conflict_type(
315        &self,
316        file_path: &str,
317        our_content: &str,
318        their_content: &str,
319    ) -> ConflictType {
320        // Check for whitespace-only differences
321        if self.normalize_whitespace(our_content) == self.normalize_whitespace(their_content) {
322            return ConflictType::Whitespace;
323        }
324
325        // Check for line ending differences
326        if self.normalize_line_endings(our_content) == self.normalize_line_endings(their_content) {
327            return ConflictType::LineEnding;
328        }
329
330        // Check for pure additions
331        if our_content.is_empty() || their_content.is_empty() {
332            return ConflictType::PureAddition;
333        }
334
335        // Check for import conflicts
336        if self.is_import_conflict(file_path, our_content, their_content) {
337            return ConflictType::ImportMerge;
338        }
339
340        // Check for structural changes
341        if self.is_structural_conflict(file_path, our_content, their_content) {
342            return ConflictType::Structural;
343        }
344
345        // Check for content overlap
346        if self.has_content_overlap(our_content, their_content) {
347            return ConflictType::ContentOverlap;
348        }
349
350        // Default to complex
351        ConflictType::Complex
352    }
353
354    /// Assess difficulty level for resolution
355    fn assess_difficulty(
356        &self,
357        conflict_type: &ConflictType,
358        our_content: &str,
359        their_content: &str,
360    ) -> ConflictDifficulty {
361        match conflict_type {
362            ConflictType::Whitespace | ConflictType::LineEnding => ConflictDifficulty::Easy,
363            ConflictType::PureAddition => {
364                if our_content.lines().count() <= 5 && their_content.lines().count() <= 5 {
365                    ConflictDifficulty::Easy
366                } else {
367                    ConflictDifficulty::Medium
368                }
369            }
370            ConflictType::ImportMerge => ConflictDifficulty::Easy,
371            ConflictType::Structural => ConflictDifficulty::Medium,
372            ConflictType::ContentOverlap => ConflictDifficulty::Medium,
373            ConflictType::Complex => ConflictDifficulty::Hard,
374        }
375    }
376
377    /// Suggest resolution strategy
378    fn suggest_resolution_strategy(
379        &self,
380        conflict_type: &ConflictType,
381        our_content: &str,
382        their_content: &str,
383    ) -> ResolutionStrategy {
384        match conflict_type {
385            ConflictType::Whitespace => {
386                if our_content.trim().len() >= their_content.trim().len() {
387                    ResolutionStrategy::TakeOurs
388                } else {
389                    ResolutionStrategy::TakeTheirs
390                }
391            }
392            ConflictType::LineEnding => {
393                ResolutionStrategy::Custom("Normalize to Unix line endings".to_string())
394            }
395            ConflictType::PureAddition => ResolutionStrategy::Merge,
396            ConflictType::ImportMerge => {
397                ResolutionStrategy::Custom("Sort and merge imports".to_string())
398            }
399            ConflictType::Structural => ResolutionStrategy::Manual,
400            ConflictType::ContentOverlap => ResolutionStrategy::Manual,
401            ConflictType::Complex => ResolutionStrategy::Manual,
402        }
403    }
404
405    /// Generate context description
406    fn generate_context(
407        &self,
408        conflict_type: &ConflictType,
409        our_content: &str,
410        their_content: &str,
411    ) -> String {
412        match conflict_type {
413            ConflictType::Whitespace => "Conflicts only in whitespace/formatting".to_string(),
414            ConflictType::LineEnding => "Conflicts only in line endings (CRLF vs LF)".to_string(),
415            ConflictType::PureAddition => {
416                format!(
417                    "Both sides added content: {} vs {} lines",
418                    our_content.lines().count(),
419                    their_content.lines().count()
420                )
421            }
422            ConflictType::ImportMerge => "Import statements that can be merged".to_string(),
423            ConflictType::Structural => {
424                "Changes to code structure (functions, classes, etc.)".to_string()
425            }
426            ConflictType::ContentOverlap => "Overlapping changes to the same content".to_string(),
427            ConflictType::Complex => "Complex conflicts requiring manual review".to_string(),
428        }
429    }
430
431    /// Check if this is an import conflict
432    fn is_import_conflict(&self, file_path: &str, our_content: &str, their_content: &str) -> bool {
433        let extension = file_path.split('.').next_back().unwrap_or("");
434
435        if let Some(patterns) = self.file_patterns.get(extension) {
436            let our_lines: Vec<&str> = our_content.lines().collect();
437            let their_lines: Vec<&str> = their_content.lines().collect();
438
439            let our_imports = our_lines.iter().all(|line| {
440                let trimmed = line.trim();
441                trimmed.is_empty() || patterns.iter().any(|pattern| trimmed.starts_with(pattern))
442            });
443
444            let their_imports = their_lines.iter().all(|line| {
445                let trimmed = line.trim();
446                trimmed.is_empty() || patterns.iter().any(|pattern| trimmed.starts_with(pattern))
447            });
448
449            return our_imports && their_imports;
450        }
451
452        false
453    }
454
455    /// Check if this is a structural conflict
456    fn is_structural_conflict(
457        &self,
458        file_path: &str,
459        our_content: &str,
460        their_content: &str,
461    ) -> bool {
462        let extension = file_path.split('.').next_back().unwrap_or("");
463
464        if let Some(patterns) = self.file_patterns.get(extension) {
465            let structural_keywords = patterns
466                .iter()
467                .filter(|p| {
468                    !p.starts_with("import") && !p.starts_with("use") && !p.starts_with("from")
469                })
470                .collect::<Vec<_>>();
471
472            let our_has_structure = our_content.lines().any(|line| {
473                structural_keywords
474                    .iter()
475                    .any(|keyword| line.trim().starts_with(*keyword))
476            });
477
478            let their_has_structure = their_content.lines().any(|line| {
479                structural_keywords
480                    .iter()
481                    .any(|keyword| line.trim().starts_with(*keyword))
482            });
483
484            return our_has_structure || their_has_structure;
485        }
486
487        false
488    }
489
490    /// Check if there's content overlap
491    fn has_content_overlap(&self, our_content: &str, their_content: &str) -> bool {
492        let our_lines: Vec<&str> = our_content.lines().collect();
493        let their_lines: Vec<&str> = their_content.lines().collect();
494
495        // Check for common lines
496        for our_line in &our_lines {
497            if their_lines.contains(our_line) && !our_line.trim().is_empty() {
498                return true;
499            }
500        }
501
502        false
503    }
504
505    /// Normalize whitespace for comparison
506    fn normalize_whitespace(&self, content: &str) -> String {
507        content
508            .lines()
509            .map(|line| line.trim())
510            .filter(|line| !line.is_empty())
511            .collect::<Vec<_>>()
512            .join("\n")
513    }
514
515    /// Normalize line endings
516    fn normalize_line_endings(&self, content: &str) -> String {
517        content.replace("\r\n", "\n").replace('\r', "\n")
518    }
519
520    /// Assess overall difficulty for a file
521    fn assess_overall_difficulty(&self, conflicts: &[ConflictRegion]) -> ConflictDifficulty {
522        if conflicts.is_empty() {
523            return ConflictDifficulty::Easy;
524        }
525
526        let has_hard = conflicts
527            .iter()
528            .any(|c| c.difficulty == ConflictDifficulty::Hard);
529        let has_medium = conflicts
530            .iter()
531            .any(|c| c.difficulty == ConflictDifficulty::Medium);
532
533        if has_hard {
534            ConflictDifficulty::Hard
535        } else if has_medium {
536            ConflictDifficulty::Medium
537        } else {
538            ConflictDifficulty::Easy
539        }
540    }
541
542    /// Generate recommendations for resolving conflicts
543    fn generate_recommendations(&self, file_analyses: &[FileConflictAnalysis]) -> Vec<String> {
544        let mut recommendations = Vec::new();
545
546        let auto_resolvable_files = file_analyses.iter().filter(|f| f.auto_resolvable).count();
547
548        if auto_resolvable_files > 0 {
549            recommendations.push(format!(
550                "🤖 {auto_resolvable_files} file(s) can be automatically resolved"
551            ));
552        }
553
554        let manual_files = file_analyses.iter().filter(|f| !f.auto_resolvable).count();
555
556        if manual_files > 0 {
557            recommendations.push(format!(
558                "✋ {manual_files} file(s) require manual resolution"
559            ));
560        }
561
562        // Count conflict types
563        let mut type_counts = HashMap::new();
564        for analysis in file_analyses {
565            for (conflict_type, count) in &analysis.conflict_summary {
566                *type_counts.entry(conflict_type.clone()).or_insert(0) += count;
567            }
568        }
569
570        for (conflict_type, count) in type_counts {
571            match conflict_type {
572                ConflictType::Whitespace => {
573                    recommendations.push(format!(
574                        "🔧 {count} whitespace conflicts can be auto-formatted"
575                    ));
576                }
577                ConflictType::ImportMerge => {
578                    recommendations.push(format!(
579                        "📦 {count} import conflicts can be merged automatically"
580                    ));
581                }
582                ConflictType::Structural => {
583                    recommendations.push(format!(
584                        "🏗️  {count} structural conflicts need careful review"
585                    ));
586                }
587                ConflictType::Complex => {
588                    recommendations.push(format!(
589                        "🔍 {count} complex conflicts require manual resolution"
590                    ));
591                }
592                _ => {}
593            }
594        }
595
596        recommendations
597    }
598}
599
600impl Default for ConflictAnalyzer {
601    fn default() -> Self {
602        Self::new()
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn test_conflict_type_classification() {
612        let analyzer = ConflictAnalyzer::new();
613
614        // Test whitespace conflict
615        let our_content = "function test() {\n    return true;\n}";
616        let their_content = "function test() {\n  return true;\n}";
617        let conflict_type = analyzer.classify_conflict_type("test.js", our_content, their_content);
618        assert_eq!(conflict_type, ConflictType::Whitespace);
619
620        // Test pure addition
621        let our_content = "";
622        let their_content = "import React from 'react';";
623        let conflict_type = analyzer.classify_conflict_type("test.js", our_content, their_content);
624        assert_eq!(conflict_type, ConflictType::PureAddition);
625
626        // Test import merge
627        let our_content = "import { useState } from 'react';";
628        let their_content = "import { useEffect } from 'react';";
629        let conflict_type = analyzer.classify_conflict_type("test.js", our_content, their_content);
630        assert_eq!(conflict_type, ConflictType::ImportMerge);
631    }
632
633    #[test]
634    fn test_difficulty_assessment() {
635        let analyzer = ConflictAnalyzer::new();
636
637        assert_eq!(
638            analyzer.assess_difficulty(&ConflictType::Whitespace, "", ""),
639            ConflictDifficulty::Easy
640        );
641
642        assert_eq!(
643            analyzer.assess_difficulty(&ConflictType::Complex, "", ""),
644            ConflictDifficulty::Hard
645        );
646
647        assert_eq!(
648            analyzer.assess_difficulty(&ConflictType::Structural, "", ""),
649            ConflictDifficulty::Medium
650        );
651    }
652
653    #[test]
654    fn test_conflict_marker_parsing() {
655        let analyzer = ConflictAnalyzer::new();
656        let content = r#"
657line before conflict
658<<<<<<< HEAD
659our content
660=======
661their content
662>>>>>>> branch
663line after conflict
664"#;
665
666        let conflicts = analyzer
667            .parse_conflict_markers("test.txt", content)
668            .unwrap();
669        assert_eq!(conflicts.len(), 1);
670        assert_eq!(conflicts[0].our_content, "our content");
671        assert_eq!(conflicts[0].their_content, "their content");
672    }
673
674    #[test]
675    fn test_import_conflict_detection() {
676        let analyzer = ConflictAnalyzer::new();
677
678        // Rust imports
679        assert!(analyzer.is_import_conflict(
680            "main.rs",
681            "use std::collections::HashMap;",
682            "use std::collections::HashSet;"
683        ));
684
685        // Python imports
686        assert!(analyzer.is_import_conflict("main.py", "import os", "import sys"));
687
688        // Not an import conflict
689        assert!(!analyzer.is_import_conflict("main.rs", "fn main() {}", "fn test() {}"));
690    }
691}