1use super::options::DiffOptions;
4use super::structured::StructuredFileDiff;
5use super::{count_line_changes_with_options, DiffEntry, DiffStatus};
6
7#[must_use]
12pub fn line_change_counts(old_content: &str, new_content: &str, options: &DiffOptions) -> LineStat {
13 let (insertions, deletions) =
14 count_line_changes_with_options(old_content, new_content, options);
15 LineStat {
16 insertions,
17 deletions,
18 }
19}
20
21#[non_exhaustive]
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum DiffDetail {
25 #[default]
27 Patch,
28 Stat,
30 NameStatus,
32 NameOnly,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub struct LineStat {
39 pub insertions: usize,
41 pub deletions: usize,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub struct DiffTotals {
48 pub files_changed: usize,
50 pub insertions: usize,
52 pub deletions: usize,
54}
55
56impl DiffTotals {
57 #[must_use]
59 pub fn from_files(files: &[FileDiff]) -> Self {
60 let lines: LineStat = files.iter().filter_map(|file| file.stat).sum();
61 Self {
62 files_changed: files.len(),
63 insertions: lines.insertions,
64 deletions: lines.deletions,
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct FileDiff {
75 pub path: String,
77 pub old_path: Option<String>,
79 pub status: DiffStatus,
81 pub old_mode: Option<String>,
83 pub new_mode: Option<String>,
85 pub binary: bool,
87 pub old_size: u64,
89 pub new_size: u64,
91 pub similarity: Option<u32>,
93 pub stat: Option<LineStat>,
95 pub structured: Option<StructuredFileDiff>,
97 pub combined: Option<crate::combined_diff_patch::CombinedStructuredTextDiff>,
99 pub combined_status: Option<String>,
101}
102
103impl DiffEntry {
104 #[must_use]
106 pub fn rename_similarity(&self) -> Option<u32> {
107 match self.status {
108 DiffStatus::Renamed | DiffStatus::Copied => self.score,
109 _ => None,
110 }
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::diff::options::DiffAlgorithm;
118 use crate::diff::structured::{structured_file_diff, SideBytes};
119 use crate::objects::FileMode;
120
121 fn patch_oracle_stat(old: &str, new: &str, options: &DiffOptions) -> LineStat {
122 let structured = structured_file_diff(
123 FileMode::REGULAR,
124 FileMode::REGULAR,
125 &SideBytes::from_bytes(old.as_bytes().to_vec()),
126 &SideBytes::from_bytes(new.as_bytes().to_vec()),
127 options.clone(),
128 );
129 LineStat::from_hunks(&structured.hunks)
130 }
131
132 fn git_numstat(old: &str, new: &str, algorithm: DiffAlgorithm) -> LineStat {
133 let dir = tempfile::tempdir().expect("tempdir");
134 std::fs::write(dir.path().join("old"), old).expect("old");
135 std::fs::write(dir.path().join("new"), new).expect("new");
136 let algo = match algorithm {
137 DiffAlgorithm::Myers => "myers",
138 DiffAlgorithm::Minimal => "minimal",
139 DiffAlgorithm::Patience => "patience",
140 DiffAlgorithm::Histogram => "histogram",
141 };
142 let out = std::process::Command::new("git")
143 .args([
144 "diff",
145 "--no-index",
146 "--numstat",
147 &format!("--diff-algorithm={algo}"),
148 "old",
149 "new",
150 ])
151 .current_dir(dir.path())
152 .env("GIT_CONFIG_GLOBAL", "/dev/null")
153 .env("GIT_CONFIG_SYSTEM", "/dev/null")
154 .env("GIT_CONFIG_NOSYSTEM", "1")
155 .output()
156 .expect("git diff");
157 assert!(
158 out.status.success() || out.status.code() == Some(1),
159 "git diff failed: {}",
160 String::from_utf8_lossy(&out.stderr)
161 );
162 let line = String::from_utf8_lossy(&out.stdout);
163 let line = line.lines().next().unwrap_or("");
164 let mut parts = line.split('\t');
165 let ins = parts.next().unwrap_or("0").parse().unwrap_or(0);
166 let del = parts.next().unwrap_or("0").parse().unwrap_or(0);
167 LineStat {
168 insertions: ins,
169 deletions: del,
170 }
171 }
172
173 #[test]
174 fn myers_stat_preserves_line_boundaries_and_repeated_lines() {
175 let options = DiffOptions::default();
176 let texts = [
177 "",
178 "a",
179 "a\n",
180 "a\na",
181 "a\na\n",
182 "a\nb\na\n",
183 "b\na\nb\n",
184 "\n",
185 "\n\n",
186 "a\r",
187 "a\r\n",
188 "a\rb\n",
189 "é\n猫",
190 "é\n猫\n",
191 "a\n\r\nb\n",
192 ];
193 for old in texts {
194 for new in texts {
195 let grit = line_change_counts(old, new, &options);
196 let patch = patch_oracle_stat(old, new, &options);
197 assert_eq!(grit, patch, "patch oracle: old={old:?}, new={new:?}");
198 if !old.contains('\r') && !new.contains('\r') {
199 let git = git_numstat(old, new, DiffAlgorithm::Myers);
200 assert_eq!(grit, git, "git numstat: old={old:?}, new={new:?}");
201 }
202 }
203 }
204
205 let mut seed = 4687u64;
206 for iteration in 0..500 {
207 let mut text = || {
208 (0..160)
209 .map(|_| {
210 seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
211 match seed >> 61 {
212 0..=3 => "repeat\n",
213 4 => "é\r\n",
214 5 => "\n",
215 6 => "x\ry\n",
216 _ => "tail\n",
217 }
218 })
219 .collect::<String>()
220 };
221 let (mut old, mut new) = (text(), text());
222 if iteration % 2 == 0 {
223 old = old.replace('\r', "");
224 new = new.replace('\r', "");
225 }
226 let prefix = "unchanged\n".repeat(120);
227 let old = format!("{prefix}{old}{prefix}");
228 let new = format!("{prefix}{new}{prefix}");
229 let grit = line_change_counts(&old, &new, &options);
230 let patch = patch_oracle_stat(&old, &new, &options);
231 assert_eq!(grit, patch, "random iteration {iteration}");
232 }
233 }
234
235 #[test]
236 fn myers_stat_large_file_small_edit() {
237 let options = DiffOptions::default();
238 let prefix = "unchanged prefix\n".repeat(10_000);
239 let suffix = "unchanged suffix\n".repeat(10_000);
240 let old = format!("{prefix}old\n{suffix}");
241 let new = format!("{prefix}new\nextra\n{suffix}");
242 let grit = line_change_counts(&old, &new, &options);
243 assert_eq!(
244 grit,
245 LineStat {
246 insertions: 2,
247 deletions: 1,
248 }
249 );
250 assert_eq!(grit, patch_oracle_stat(&old, &new, &options));
251 assert_eq!(grit, git_numstat(&old, &new, DiffAlgorithm::Myers));
252 }
253
254 #[test]
255 fn myers_stat_repetitive_po_like_fixture_matches_git() {
256 let options = DiffOptions::default();
257 let repeated = "msgid \"entry\"\nmsgstr \"same translation line\"\n".repeat(15_000);
258 let mut old = repeated.clone();
259 old.push_str("msgid \"removed\"\nmsgstr \"gone\"\n");
260 let mut new = repeated;
261 new.push_str("msgid \"added-a\"\nmsgstr \"one\"\n");
262 new.push_str("msgid \"added-b\"\nmsgstr \"two\"\n");
263 let grit = line_change_counts(&old, &new, &options);
264 let git = git_numstat(&old, &new, DiffAlgorithm::Myers);
265 assert_eq!(grit, git, "repetitive .po-like numstat vs git");
266 assert_eq!(grit, patch_oracle_stat(&old, &new, &options));
267 }
268
269 #[test]
270 fn line_stat_matches_git_and_repeated_anchor_fixture() {
271 let anchor = "fn anchor() {\n";
272 let old = format!("{anchor}{anchor}// old body\n");
273 let mut new = String::new();
274 for i in 0..800 {
275 new.push_str(&format!("// inserted block {i}\n"));
276 }
277 new.push_str(&anchor);
278 new.push_str("// new tail\n");
279
280 for algorithm in [
281 DiffAlgorithm::Myers,
282 DiffAlgorithm::Minimal,
283 DiffAlgorithm::Patience,
284 DiffAlgorithm::Histogram,
285 ] {
286 let options = DiffOptions::default()
287 .with_algorithm(algorithm)
288 .with_indent_heuristic(true);
289 let grit = line_change_counts(&old, &new, &options);
290 let git = git_numstat(&old, &new, algorithm);
291 assert_eq!(grit, git, "{algorithm:?} numstat vs git");
292 }
293 }
294}