ricecoder_files/
diff.rs

1//! Diff generation and application for file changes
2
3use crate::error::FileError;
4use crate::models::{DiffHunk, DiffLine, DiffStats, FileDiff};
5use similar::{ChangeTag, TextDiff};
6use std::path::PathBuf;
7
8/// Generates and applies diffs between file versions
9#[derive(Debug, Clone)]
10pub struct DiffEngine;
11
12impl DiffEngine {
13    /// Creates a new DiffEngine instance
14    pub fn new() -> Self {
15        Self
16    }
17
18    /// Generates a unified diff between two file versions
19    ///
20    /// # Arguments
21    /// * `old` - The original file content
22    /// * `new` - The new file content
23    /// * `path` - The path of the file being diffed
24    ///
25    /// # Returns
26    /// A `FileDiff` containing hunks and statistics
27    pub fn generate_unified_diff(
28        &self,
29        old: &str,
30        new: &str,
31        path: PathBuf,
32    ) -> Result<FileDiff, FileError> {
33        let hunks = self.extract_hunks(old, new)?;
34        let stats = self.compute_stats_from_hunks(&hunks);
35
36        Ok(FileDiff { path, hunks, stats })
37    }
38
39    /// Generates a side-by-side diff between two file versions
40    ///
41    /// # Arguments
42    /// * `old` - The original file content
43    /// * `new` - The new file content
44    /// * `path` - The path of the file being diffed
45    ///
46    /// # Returns
47    /// A `FileDiff` containing hunks and statistics
48    pub fn generate_side_by_side_diff(
49        &self,
50        old: &str,
51        new: &str,
52        path: PathBuf,
53    ) -> Result<FileDiff, FileError> {
54        // For side-by-side, we use the same hunk extraction as unified
55        // The difference is in presentation, which is handled by the consumer
56        self.generate_unified_diff(old, new, path)
57    }
58
59    /// Applies a single hunk to source content
60    ///
61    /// # Arguments
62    /// * `source` - The source content to apply the hunk to
63    /// * `hunk` - The hunk to apply
64    ///
65    /// # Returns
66    /// The content with the hunk applied
67    pub fn apply_hunk(&self, source: &str, hunk: &DiffHunk) -> Result<String, FileError> {
68        let lines: Vec<&str> = source.lines().collect();
69        let mut result = Vec::new();
70
71        // Copy lines before the hunk
72        if hunk.old_start > 0 {
73            result.extend_from_slice(&lines[0..hunk.old_start - 1]);
74        }
75
76        // Apply the hunk lines
77        for line in &hunk.lines {
78            match line {
79                DiffLine::Context(content) => {
80                    result.push(content.as_str());
81                }
82                DiffLine::Added(content) => {
83                    result.push(content.as_str());
84                }
85                DiffLine::Removed(_) => {
86                    // Skip removed lines
87                }
88            }
89        }
90
91        // Copy lines after the hunk
92        let end_line = hunk.old_start + hunk.old_count - 1;
93        if end_line < lines.len() {
94            result.extend_from_slice(&lines[end_line..]);
95        }
96
97        Ok(result.join("\n"))
98    }
99
100    /// Extracts hunks from a text diff
101    fn extract_hunks(&self, old: &str, new: &str) -> Result<Vec<DiffHunk>, FileError> {
102        let text_diff = TextDiff::from_lines(old, new);
103
104        let mut hunks = Vec::new();
105        let mut current_hunk: Option<DiffHunk> = None;
106        let mut old_line_num = 1;
107        let mut new_line_num = 1;
108
109        for change in text_diff.iter_all_changes() {
110            let line_content = change.value();
111
112            match change.tag() {
113                ChangeTag::Delete => {
114                    if current_hunk.is_none() {
115                        current_hunk = Some(DiffHunk {
116                            old_start: old_line_num,
117                            old_count: 0,
118                            new_start: new_line_num,
119                            new_count: 0,
120                            lines: Vec::new(),
121                        });
122                    }
123
124                    if let Some(ref mut hunk) = current_hunk {
125                        hunk.old_count += 1;
126                        hunk.lines
127                            .push(DiffLine::Removed(line_content.trim_end().to_string()));
128                    }
129
130                    old_line_num += 1;
131                }
132                ChangeTag::Insert => {
133                    if current_hunk.is_none() {
134                        current_hunk = Some(DiffHunk {
135                            old_start: old_line_num,
136                            old_count: 0,
137                            new_start: new_line_num,
138                            new_count: 0,
139                            lines: Vec::new(),
140                        });
141                    }
142
143                    if let Some(ref mut hunk) = current_hunk {
144                        hunk.new_count += 1;
145                        hunk.lines
146                            .push(DiffLine::Added(line_content.trim_end().to_string()));
147                    }
148
149                    new_line_num += 1;
150                }
151                ChangeTag::Equal => {
152                    if let Some(ref mut hunk) = current_hunk {
153                        // If we have a hunk and encounter context, add it to the hunk
154                        hunk.old_count += 1;
155                        hunk.new_count += 1;
156                        hunk.lines
157                            .push(DiffLine::Context(line_content.trim_end().to_string()));
158                    }
159
160                    old_line_num += 1;
161                    new_line_num += 1;
162                }
163            }
164        }
165
166        if let Some(hunk) = current_hunk {
167            if !hunk.lines.is_empty() {
168                hunks.push(hunk);
169            }
170        }
171
172        Ok(hunks)
173    }
174
175    /// Computes statistics for a diff
176    ///
177    /// # Arguments
178    /// * `diff` - The diff to compute statistics for
179    ///
180    /// # Returns
181    /// Statistics about the diff (additions, deletions, files changed)
182    pub fn compute_stats(&self, diff: &FileDiff) -> DiffStats {
183        let mut additions = 0;
184        let mut deletions = 0;
185
186        for hunk in &diff.hunks {
187            for line in &hunk.lines {
188                match line {
189                    DiffLine::Added(_) => additions += 1,
190                    DiffLine::Removed(_) => deletions += 1,
191                    DiffLine::Context(_) => {}
192                }
193            }
194        }
195
196        DiffStats {
197            additions,
198            deletions,
199            files_changed: if diff.hunks.is_empty() { 0 } else { 1 },
200        }
201    }
202
203    /// Internal helper to compute stats from hunks
204    fn compute_stats_from_hunks(&self, hunks: &[DiffHunk]) -> DiffStats {
205        let mut additions = 0;
206        let mut deletions = 0;
207
208        for hunk in hunks {
209            for line in &hunk.lines {
210                match line {
211                    DiffLine::Added(_) => additions += 1,
212                    DiffLine::Removed(_) => deletions += 1,
213                    DiffLine::Context(_) => {}
214                }
215            }
216        }
217
218        DiffStats {
219            additions,
220            deletions,
221            files_changed: if hunks.is_empty() { 0 } else { 1 },
222        }
223    }
224}
225
226impl Default for DiffEngine {
227    fn default() -> Self {
228        Self::new()
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_generate_unified_diff_simple() {
238        let engine = DiffEngine::new();
239        let old = "line 1\nline 2\nline 3\n";
240        let new = "line 1\nline 2 modified\nline 3\n";
241
242        let diff = engine
243            .generate_unified_diff(old, new, PathBuf::from("test.txt"))
244            .unwrap();
245
246        assert!(!diff.hunks.is_empty());
247        assert_eq!(diff.stats.additions, 1);
248        assert_eq!(diff.stats.deletions, 1);
249    }
250
251    #[test]
252    fn test_generate_unified_diff_no_changes() {
253        let engine = DiffEngine::new();
254        let content = "line 1\nline 2\nline 3\n";
255
256        let diff = engine
257            .generate_unified_diff(content, content, PathBuf::from("test.txt"))
258            .unwrap();
259
260        assert_eq!(diff.stats.additions, 0);
261        assert_eq!(diff.stats.deletions, 0);
262    }
263
264    #[test]
265    fn test_apply_hunk_simple() {
266        let engine = DiffEngine::new();
267        let hunk = DiffHunk {
268            old_start: 2,
269            old_count: 1,
270            new_start: 2,
271            new_count: 1,
272            lines: vec![DiffLine::Added("new line".to_string())],
273        };
274
275        let source = "line 1\nline 2\nline 3\n";
276        let result = engine.apply_hunk(source, &hunk).unwrap();
277
278        assert!(result.contains("new line"));
279    }
280
281    #[test]
282    fn test_compute_stats() {
283        let engine = DiffEngine::new();
284        let diff = FileDiff {
285            path: PathBuf::from("test.txt"),
286            hunks: vec![DiffHunk {
287                old_start: 1,
288                old_count: 2,
289                new_start: 1,
290                new_count: 3,
291                lines: vec![
292                    DiffLine::Removed("old".to_string()),
293                    DiffLine::Added("new1".to_string()),
294                    DiffLine::Added("new2".to_string()),
295                ],
296            }],
297            stats: DiffStats {
298                additions: 0,
299                deletions: 0,
300                files_changed: 0,
301            },
302        };
303
304        let stats = engine.compute_stats(&diff);
305        assert_eq!(stats.additions, 2);
306        assert_eq!(stats.deletions, 1);
307        assert_eq!(stats.files_changed, 1);
308    }
309}