Skip to main content

forgekit_core/diff/
mod.rs

1use std::path::PathBuf;
2
3pub struct UnifiedDiff {
4    pub old_path: PathBuf,
5    pub new_path: PathBuf,
6    pub hunks: Vec<Hunk>,
7}
8
9pub struct Hunk {
10    pub old_start: u32,
11    pub old_count: u32,
12    pub new_start: u32,
13    pub new_count: u32,
14    pub lines: Vec<DiffLine>,
15}
16
17#[derive(Debug, PartialEq, Eq, Clone)]
18pub enum DiffLine {
19    Context(String),
20    Add(String),
21    Remove(String),
22}
23
24#[derive(Debug, PartialEq, Eq)]
25pub struct DiffStats {
26    pub additions: usize,
27    pub removals: usize,
28    pub files_changed: usize,
29}
30
31impl UnifiedDiff {
32    pub fn generate(old: &str, new: &str, old_path: &str, new_path: &str) -> Self {
33        let changes = similar::TextDiff::from_lines(old, new);
34        let mut hunks = Vec::new();
35
36        for hunk in changes.unified_diff().iter_hunks() {
37            let mut lines = Vec::new();
38            let mut old_start = None;
39            let mut old_count = 0u32;
40            let mut new_start = None;
41            let mut new_count = 0u32;
42
43            for change in hunk.iter_changes() {
44                if old_start.is_none() && change.old_index().is_some() {
45                    old_start = Some(change.old_index().unwrap() as u32);
46                }
47                if new_start.is_none() && change.new_index().is_some() {
48                    new_start = Some(change.new_index().unwrap() as u32);
49                }
50                match change.tag() {
51                    similar::ChangeTag::Equal => {
52                        lines.push(DiffLine::Context(change.to_string()));
53                        old_count += 1;
54                        new_count += 1;
55                    }
56                    similar::ChangeTag::Delete => {
57                        lines.push(DiffLine::Remove(change.to_string()));
58                        old_count += 1;
59                    }
60                    similar::ChangeTag::Insert => {
61                        lines.push(DiffLine::Add(change.to_string()));
62                        new_count += 1;
63                    }
64                }
65            }
66
67            if !lines.is_empty() {
68                hunks.push(Hunk {
69                    old_start: old_start.unwrap_or(0) + 1,
70                    old_count,
71                    new_start: new_start.unwrap_or(0) + 1,
72                    new_count,
73                    lines,
74                });
75            }
76        }
77
78        Self {
79            old_path: PathBuf::from(old_path),
80            new_path: PathBuf::from(new_path),
81            hunks,
82        }
83    }
84
85    pub fn render(&self) -> String {
86        let mut out = String::new();
87        out.push_str(&format!(
88            "--- {}\n+++ {}\n",
89            self.old_path.display(),
90            self.new_path.display()
91        ));
92
93        for hunk in &self.hunks {
94            out.push_str(&format!(
95                "@@ -{},{} +{},{} @@\n",
96                hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
97            ));
98            for line in &hunk.lines {
99                match line {
100                    DiffLine::Context(s) => out.push_str(&format!(" {}", s)),
101                    DiffLine::Add(s) => out.push_str(&format!("+{}", s)),
102                    DiffLine::Remove(s) => out.push_str(&format!("-{}", s)),
103                }
104            }
105        }
106
107        out
108    }
109
110    pub fn apply(&self, original: &str) -> anyhow::Result<String> {
111        let mut old_lines: Vec<&str> = original.lines().collect();
112        let mut offset: i64 = 0;
113
114        for hunk in &self.hunks {
115            let start = (hunk.old_start as i64 - 1 + offset) as usize;
116            let mut new_lines = Vec::new();
117
118            for line in &hunk.lines {
119                match line {
120                    DiffLine::Context(s) => {
121                        new_lines.push(s.trim_end_matches('\n').trim_end_matches('\r'));
122                    }
123                    DiffLine::Remove(_) => {}
124                    DiffLine::Add(s) => {
125                        new_lines.push(s.trim_end_matches('\n').trim_end_matches('\r'));
126                    }
127                }
128            }
129
130            let end = start + hunk.old_count as usize;
131            if end <= old_lines.len() {
132                let removed = end - start;
133                old_lines.splice(start..end, new_lines.iter().copied());
134                offset += new_lines.len() as i64 - removed as i64;
135            }
136        }
137
138        Ok(old_lines.join("\n"))
139    }
140
141    pub fn reverse(&self) -> Self {
142        let hunks = self
143            .hunks
144            .iter()
145            .map(|h| {
146                let lines = h
147                    .lines
148                    .iter()
149                    .map(|l| match l {
150                        DiffLine::Add(s) => DiffLine::Remove(s.clone()),
151                        DiffLine::Remove(s) => DiffLine::Add(s.clone()),
152                        DiffLine::Context(s) => DiffLine::Context(s.clone()),
153                    })
154                    .collect();
155                Hunk {
156                    old_start: h.new_start,
157                    old_count: h.new_count,
158                    new_start: h.old_start,
159                    new_count: h.old_count,
160                    lines,
161                }
162            })
163            .collect();
164
165        Self {
166            old_path: self.new_path.clone(),
167            new_path: self.old_path.clone(),
168            hunks,
169        }
170    }
171
172    pub fn stats(&self) -> DiffStats {
173        let mut additions = 0;
174        let mut removals = 0;
175
176        for hunk in &self.hunks {
177            for line in &hunk.lines {
178                match line {
179                    DiffLine::Add(_) => additions += 1,
180                    DiffLine::Remove(_) => removals += 1,
181                    DiffLine::Context(_) => {}
182                }
183            }
184        }
185
186        DiffStats {
187            additions,
188            removals,
189            files_changed: 1,
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_generate_no_changes() {
200        let diff = UnifiedDiff::generate("hello\n", "hello\n", "a.txt", "a.txt");
201        assert!(diff.hunks.is_empty());
202    }
203
204    #[test]
205    fn test_generate_add_line() {
206        let diff = UnifiedDiff::generate("line1\n", "line1\nline2\n", "a.txt", "a.txt");
207        assert!(!diff.hunks.is_empty());
208
209        let has_add = diff
210            .hunks
211            .iter()
212            .any(|h| h.lines.iter().any(|l| matches!(l, DiffLine::Add(_))));
213        assert!(has_add, "should have at least one Add line");
214    }
215
216    #[test]
217    fn test_generate_remove_line() {
218        let diff = UnifiedDiff::generate("line1\nline2\n", "line1\n", "a.txt", "a.txt");
219        assert!(!diff.hunks.is_empty());
220
221        let has_remove = diff
222            .hunks
223            .iter()
224            .any(|h| h.lines.iter().any(|l| matches!(l, DiffLine::Remove(_))));
225        assert!(has_remove, "should have at least one Remove line");
226    }
227
228    #[test]
229    fn test_generate_replace_line() {
230        let diff = UnifiedDiff::generate("old\n", "new\n", "a.txt", "a.txt");
231        assert!(!diff.hunks.is_empty());
232
233        let stats = diff.stats();
234        assert_eq!(stats.additions, 1);
235        assert_eq!(stats.removals, 1);
236    }
237
238    #[test]
239    fn test_stats_counts() {
240        let diff = UnifiedDiff::generate("a\nb\nc\n", "a\nx\nd\n", "old.txt", "new.txt");
241        let stats = diff.stats();
242        assert_eq!(stats.files_changed, 1);
243        assert!(stats.additions >= 2);
244        assert!(stats.removals >= 2);
245    }
246
247    #[test]
248    fn test_to_string_format() {
249        let diff = UnifiedDiff::generate("hello\n", "world\n", "a.txt", "b.txt");
250        let s = diff.render();
251        assert!(s.starts_with("--- a.txt\n+++ b.txt\n"));
252        assert!(s.contains("@@"));
253    }
254
255    #[test]
256    fn test_apply_roundtrip_add() {
257        let old = "line1\n";
258        let new = "line1\nline2\n";
259        let diff = UnifiedDiff::generate(old, new, "a.txt", "a.txt");
260        let applied = diff.apply(old).unwrap();
261        assert_eq!(applied, "line1\nline2");
262    }
263
264    #[test]
265    fn test_apply_roundtrip_remove() {
266        let old = "line1\nline2\n";
267        let new = "line1\n";
268        let diff = UnifiedDiff::generate(old, new, "a.txt", "a.txt");
269        let applied = diff.apply(old).unwrap();
270        assert_eq!(applied, "line1");
271    }
272
273    #[test]
274    fn test_apply_roundtrip_replace() {
275        let old = "a\nb\nc\n";
276        let new = "a\nx\nc\n";
277        let diff = UnifiedDiff::generate(old, new, "a.txt", "a.txt");
278        let applied = diff.apply(old).unwrap();
279        assert_eq!(applied, "a\nx\nc");
280    }
281
282    #[test]
283    fn test_reverse_then_apply() {
284        let old = "alpha\nbeta\n";
285        let new = "alpha\ngamma\n";
286        let diff = UnifiedDiff::generate(old, new, "a.txt", "a.txt");
287        let reversed = diff.reverse();
288        let applied = reversed.apply(new).unwrap();
289        assert_eq!(applied, "alpha\nbeta");
290    }
291
292    #[test]
293    fn test_generate_empty_to_content() {
294        let diff = UnifiedDiff::generate("", "new content\n", "a.txt", "a.txt");
295        assert!(!diff.hunks.is_empty());
296        let stats = diff.stats();
297        assert_eq!(stats.additions, 1);
298        assert_eq!(stats.removals, 0);
299    }
300
301    #[test]
302    fn test_generate_content_to_empty() {
303        let diff = UnifiedDiff::generate("old content\n", "", "a.txt", "a.txt");
304        assert!(!diff.hunks.is_empty());
305        let stats = diff.stats();
306        assert_eq!(stats.additions, 0);
307        assert_eq!(stats.removals, 1);
308    }
309
310    #[test]
311    fn test_apply_preserves_context() {
312        let old = "line1\nline2\nline3\nline4\n";
313        let new = "line1\nline2\nMODIFIED\nline4\n";
314        let diff = UnifiedDiff::generate(old, new, "a.txt", "a.txt");
315        let applied = diff.apply(old).unwrap();
316        assert_eq!(applied, "line1\nline2\nMODIFIED\nline4");
317    }
318
319    #[test]
320    fn test_multiline_hunk() {
321        let old = "a\nb\nc\nd\ne\n";
322        let new = "a\nB\nC\nd\ne\n";
323        let diff = UnifiedDiff::generate(old, new, "a.txt", "a.txt");
324        let stats = diff.stats();
325        assert!(stats.additions >= 2);
326        assert!(stats.removals >= 2);
327    }
328}