ricecoder_generation/
conflict_detector.rs

1//! Conflict detection for generated files
2//!
3//! Detects file conflicts before writing and computes diffs between old and new content.
4//! Implements requirements:
5//! - Requirement 1.5: Detect conflicts when generated files would overwrite existing files
6//! - Requirement 4.1: Compute diffs between old and new content
7
8use crate::error::GenerationError;
9use crate::models::GeneratedFile;
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::{Path, PathBuf};
13
14/// Information about a file conflict
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct FileConflictInfo {
17    /// Path to the conflicting file
18    pub path: PathBuf,
19    /// Existing file content
20    pub old_content: String,
21    /// New generated content
22    pub new_content: String,
23    /// Diff between old and new content
24    pub diff: FileDiff,
25}
26
27/// Represents a diff between two file versions
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct FileDiff {
30    /// Lines that were added
31    pub added_lines: Vec<DiffLine>,
32    /// Lines that were removed
33    pub removed_lines: Vec<DiffLine>,
34    /// Lines that were modified
35    pub modified_lines: Vec<(DiffLine, DiffLine)>,
36    /// Total number of changes
37    pub total_changes: usize,
38}
39
40/// A single line in a diff
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct DiffLine {
43    /// Line number in the file
44    pub line_number: usize,
45    /// Content of the line
46    pub content: String,
47}
48
49/// Detects file conflicts before writing
50///
51/// Implements requirements:
52/// - Requirement 1.5: Detect conflicts when generated files would overwrite existing files
53/// - Requirement 4.1: Compute diffs between old and new content
54pub struct ConflictDetector;
55
56impl ConflictDetector {
57    /// Create a new conflict detector
58    pub fn new() -> Self {
59        Self
60    }
61
62    /// Detect conflicts for a set of generated files
63    ///
64    /// Checks if any generated files would overwrite existing files and computes diffs.
65    ///
66    /// # Arguments
67    /// * `files` - Generated files to check
68    /// * `target_dir` - Target directory where files would be written
69    ///
70    /// # Returns
71    /// List of detected conflicts
72    ///
73    /// # Requirements
74    /// - Requirement 1.5: Detect conflicts when generated files would overwrite existing files
75    /// - Requirement 4.1: Compute diffs between old and new content
76    pub fn detect(
77        &self,
78        files: &[GeneratedFile],
79        target_dir: &Path,
80    ) -> Result<Vec<FileConflictInfo>, GenerationError> {
81        let mut conflicts = Vec::new();
82
83        for file in files {
84            let file_path = target_dir.join(&file.path);
85
86            // Check if file already exists
87            if file_path.exists() {
88                // Read existing content
89                let old_content = fs::read_to_string(&file_path).map_err(|e| {
90                    GenerationError::ValidationError {
91                        file: file.path.clone(),
92                        line: 0,
93                        message: format!("Failed to read existing file: {}", e),
94                    }
95                })?;
96
97                // Compute diff
98                let diff = self.compute_diff(&old_content, &file.content)?;
99
100                conflicts.push(FileConflictInfo {
101                    path: file_path,
102                    old_content,
103                    new_content: file.content.clone(),
104                    diff,
105                });
106            }
107        }
108
109        Ok(conflicts)
110    }
111
112    /// Detect a single file conflict
113    ///
114    /// # Arguments
115    /// * `file_path` - Path to the file to check
116    /// * `new_content` - New content to be written
117    ///
118    /// # Returns
119    /// Conflict info if file exists, None otherwise
120    pub fn detect_single(
121        &self,
122        file_path: &Path,
123        new_content: &str,
124    ) -> Result<Option<FileConflictInfo>, GenerationError> {
125        if !file_path.exists() {
126            return Ok(None);
127        }
128
129        let old_content =
130            fs::read_to_string(file_path).map_err(|e| GenerationError::ValidationError {
131                file: file_path.to_string_lossy().to_string(),
132                line: 0,
133                message: format!("Failed to read existing file: {}", e),
134            })?;
135
136        let diff = self.compute_diff(&old_content, new_content)?;
137
138        Ok(Some(FileConflictInfo {
139            path: file_path.to_path_buf(),
140            old_content,
141            new_content: new_content.to_string(),
142            diff,
143        }))
144    }
145
146    /// Compute a diff between two file contents
147    ///
148    /// Uses a simple line-based diff algorithm to identify added, removed, and modified lines.
149    ///
150    /// # Arguments
151    /// * `old_content` - Original file content
152    /// * `new_content` - New file content
153    ///
154    /// # Returns
155    /// Diff information
156    fn compute_diff(
157        &self,
158        old_content: &str,
159        new_content: &str,
160    ) -> Result<FileDiff, GenerationError> {
161        let old_lines: Vec<&str> = old_content.lines().collect();
162        let new_lines: Vec<&str> = new_content.lines().collect();
163
164        let mut added_lines = Vec::new();
165        let mut removed_lines = Vec::new();
166        let mut modified_lines = Vec::new();
167
168        // Simple line-based diff: compare line by line
169        let max_lines = old_lines.len().max(new_lines.len());
170
171        for i in 0..max_lines {
172            let old_line = old_lines.get(i).copied();
173            let new_line = new_lines.get(i).copied();
174
175            match (old_line, new_line) {
176                (Some(old), Some(new)) if old != new => {
177                    // Line was modified
178                    modified_lines.push((
179                        DiffLine {
180                            line_number: i + 1,
181                            content: old.to_string(),
182                        },
183                        DiffLine {
184                            line_number: i + 1,
185                            content: new.to_string(),
186                        },
187                    ));
188                }
189                (Some(old), None) => {
190                    // Line was removed
191                    removed_lines.push(DiffLine {
192                        line_number: i + 1,
193                        content: old.to_string(),
194                    });
195                }
196                (None, Some(new)) => {
197                    // Line was added
198                    added_lines.push(DiffLine {
199                        line_number: i + 1,
200                        content: new.to_string(),
201                    });
202                }
203                (Some(_), Some(_)) => {
204                    // Lines are identical, no change
205                }
206                (None, None) => {
207                    // Both are None, shouldn't happen
208                }
209            }
210        }
211
212        let total_changes = added_lines.len() + removed_lines.len() + modified_lines.len();
213
214        Ok(FileDiff {
215            added_lines,
216            removed_lines,
217            modified_lines,
218            total_changes,
219        })
220    }
221
222    /// Check if two files have the same content
223    ///
224    /// # Arguments
225    /// * `old_content` - Original content
226    /// * `new_content` - New content
227    ///
228    /// # Returns
229    /// True if contents are identical
230    pub fn is_identical(&self, old_content: &str, new_content: &str) -> bool {
231        old_content == new_content
232    }
233
234    /// Get a human-readable summary of a diff
235    ///
236    /// # Arguments
237    /// * `diff` - Diff to summarize
238    ///
239    /// # Returns
240    /// Summary string
241    pub fn summarize_diff(&self, diff: &FileDiff) -> String {
242        format!(
243            "{} added, {} removed, {} modified",
244            diff.added_lines.len(),
245            diff.removed_lines.len(),
246            diff.modified_lines.len()
247        )
248    }
249}
250
251impl Default for ConflictDetector {
252    fn default() -> Self {
253        Self::new()
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use tempfile::TempDir;
261
262    #[test]
263    fn test_create_conflict_detector() {
264        let _detector = ConflictDetector::new();
265    }
266
267    #[test]
268    fn test_detect_no_conflicts() {
269        let temp_dir = TempDir::new().unwrap();
270        let detector = ConflictDetector::new();
271
272        let files = vec![GeneratedFile {
273            path: "src/main.rs".to_string(),
274            content: "fn main() {}".to_string(),
275            language: "rust".to_string(),
276        }];
277
278        let conflicts = detector.detect(&files, temp_dir.path()).unwrap();
279        assert_eq!(conflicts.len(), 0);
280    }
281
282    #[test]
283    fn test_detect_single_conflict() {
284        let temp_dir = TempDir::new().unwrap();
285        let detector = ConflictDetector::new();
286
287        // Create existing file
288        let file_path = temp_dir.path().join("src").join("main.rs");
289        fs::create_dir_all(file_path.parent().unwrap()).unwrap();
290        fs::write(&file_path, "// old content").unwrap();
291
292        let files = vec![GeneratedFile {
293            path: "src/main.rs".to_string(),
294            content: "// new content".to_string(),
295            language: "rust".to_string(),
296        }];
297
298        let conflicts = detector.detect(&files, temp_dir.path()).unwrap();
299        assert_eq!(conflicts.len(), 1);
300        assert_eq!(conflicts[0].old_content, "// old content");
301        assert_eq!(conflicts[0].new_content, "// new content");
302    }
303
304    #[test]
305    fn test_detect_multiple_conflicts() {
306        let temp_dir = TempDir::new().unwrap();
307        let detector = ConflictDetector::new();
308
309        // Create existing files
310        let file1_path = temp_dir.path().join("src").join("main.rs");
311        fs::create_dir_all(file1_path.parent().unwrap()).unwrap();
312        fs::write(&file1_path, "// old main").unwrap();
313
314        let file2_path = temp_dir.path().join("src").join("lib.rs");
315        fs::write(&file2_path, "// old lib").unwrap();
316
317        let files = vec![
318            GeneratedFile {
319                path: "src/main.rs".to_string(),
320                content: "// new main".to_string(),
321                language: "rust".to_string(),
322            },
323            GeneratedFile {
324                path: "src/lib.rs".to_string(),
325                content: "// new lib".to_string(),
326                language: "rust".to_string(),
327            },
328        ];
329
330        let conflicts = detector.detect(&files, temp_dir.path()).unwrap();
331        assert_eq!(conflicts.len(), 2);
332    }
333
334    #[test]
335    fn test_compute_diff_identical() {
336        let detector = ConflictDetector::new();
337        let old = "line 1\nline 2\nline 3";
338        let new = "line 1\nline 2\nline 3";
339
340        let diff = detector.compute_diff(old, new).unwrap();
341        assert_eq!(diff.added_lines.len(), 0);
342        assert_eq!(diff.removed_lines.len(), 0);
343        assert_eq!(diff.modified_lines.len(), 0);
344    }
345
346    #[test]
347    fn test_compute_diff_added_lines() {
348        let detector = ConflictDetector::new();
349        let old = "line 1\nline 2";
350        let new = "line 1\nline 2\nline 3\nline 4";
351
352        let diff = detector.compute_diff(old, new).unwrap();
353        assert_eq!(diff.added_lines.len(), 2);
354        assert_eq!(diff.removed_lines.len(), 0);
355        assert_eq!(diff.modified_lines.len(), 0);
356    }
357
358    #[test]
359    fn test_compute_diff_removed_lines() {
360        let detector = ConflictDetector::new();
361        let old = "line 1\nline 2\nline 3\nline 4";
362        let new = "line 1\nline 2";
363
364        let diff = detector.compute_diff(old, new).unwrap();
365        assert_eq!(diff.added_lines.len(), 0);
366        assert_eq!(diff.removed_lines.len(), 2);
367        assert_eq!(diff.modified_lines.len(), 0);
368    }
369
370    #[test]
371    fn test_compute_diff_modified_lines() {
372        let detector = ConflictDetector::new();
373        let old = "line 1\nline 2\nline 3";
374        let new = "line 1\nmodified line 2\nline 3";
375
376        let diff = detector.compute_diff(old, new).unwrap();
377        assert_eq!(diff.added_lines.len(), 0);
378        assert_eq!(diff.removed_lines.len(), 0);
379        assert_eq!(diff.modified_lines.len(), 1);
380    }
381
382    #[test]
383    fn test_is_identical_true() {
384        let detector = ConflictDetector::new();
385        let content = "line 1\nline 2\nline 3";
386        assert!(detector.is_identical(content, content));
387    }
388
389    #[test]
390    fn test_is_identical_false() {
391        let detector = ConflictDetector::new();
392        let old = "line 1\nline 2";
393        let new = "line 1\nline 2\nline 3";
394        assert!(!detector.is_identical(old, new));
395    }
396
397    #[test]
398    fn test_summarize_diff() {
399        let detector = ConflictDetector::new();
400        let diff = FileDiff {
401            added_lines: vec![DiffLine {
402                line_number: 1,
403                content: "added".to_string(),
404            }],
405            removed_lines: vec![DiffLine {
406                line_number: 2,
407                content: "removed".to_string(),
408            }],
409            modified_lines: vec![(
410                DiffLine {
411                    line_number: 3,
412                    content: "old".to_string(),
413                },
414                DiffLine {
415                    line_number: 3,
416                    content: "new".to_string(),
417                },
418            )],
419            total_changes: 3,
420        };
421
422        let summary = detector.summarize_diff(&diff);
423        assert!(summary.contains("1 added"));
424        assert!(summary.contains("1 removed"));
425        assert!(summary.contains("1 modified"));
426    }
427
428    #[test]
429    fn test_detect_single_no_conflict() {
430        let temp_dir = TempDir::new().unwrap();
431        let detector = ConflictDetector::new();
432
433        let file_path = temp_dir.path().join("nonexistent.rs");
434        let result = detector.detect_single(&file_path, "content").unwrap();
435        assert!(result.is_none());
436    }
437
438    #[test]
439    fn test_detect_single_with_conflict() {
440        let temp_dir = TempDir::new().unwrap();
441        let detector = ConflictDetector::new();
442
443        let file_path = temp_dir.path().join("existing.rs");
444        fs::write(&file_path, "old content").unwrap();
445
446        let result = detector.detect_single(&file_path, "new content").unwrap();
447        assert!(result.is_some());
448
449        let conflict = result.unwrap();
450        assert_eq!(conflict.old_content, "old content");
451        assert_eq!(conflict.new_content, "new content");
452    }
453}