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)]
71pub struct FileDiff {
72 pub path: String,
74 pub old_path: Option<String>,
76 pub status: DiffStatus,
78 pub old_mode: Option<String>,
80 pub new_mode: Option<String>,
82 pub binary: bool,
84 pub similarity: Option<u32>,
86 pub stat: Option<LineStat>,
88 pub structured: Option<StructuredFileDiff>,
90 pub combined: Option<crate::combined_diff_patch::CombinedStructuredTextDiff>,
92 pub combined_status: Option<String>,
94}
95
96impl DiffEntry {
97 #[must_use]
99 pub fn rename_similarity(&self) -> Option<u32> {
100 match self.status {
101 DiffStatus::Renamed | DiffStatus::Copied => self.score,
102 _ => None,
103 }
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use crate::diff::options::DiffAlgorithm;
111 use crate::diff::structured::{structured_file_diff, SideBytes};
112 use crate::objects::FileMode;
113
114 fn patch_oracle_stat(old: &str, new: &str, options: &DiffOptions) -> LineStat {
115 let structured = structured_file_diff(
116 FileMode::REGULAR,
117 FileMode::REGULAR,
118 &SideBytes::from_bytes(old.as_bytes().to_vec()),
119 &SideBytes::from_bytes(new.as_bytes().to_vec()),
120 options.clone(),
121 );
122 LineStat::from_hunks(&structured.hunks)
123 }
124
125 fn git_numstat(old: &str, new: &str, algorithm: DiffAlgorithm) -> LineStat {
126 let dir = tempfile::tempdir().expect("tempdir");
127 std::fs::write(dir.path().join("old"), old).expect("old");
128 std::fs::write(dir.path().join("new"), new).expect("new");
129 let algo = match algorithm {
130 DiffAlgorithm::Myers => "myers",
131 DiffAlgorithm::Minimal => "minimal",
132 DiffAlgorithm::Patience => "patience",
133 DiffAlgorithm::Histogram => "histogram",
134 };
135 let out = std::process::Command::new("git")
136 .args([
137 "diff",
138 "--no-index",
139 "--numstat",
140 &format!("--diff-algorithm={algo}"),
141 "old",
142 "new",
143 ])
144 .current_dir(dir.path())
145 .env("GIT_CONFIG_GLOBAL", "/dev/null")
146 .env("GIT_CONFIG_SYSTEM", "/dev/null")
147 .env("GIT_CONFIG_NOSYSTEM", "1")
148 .output()
149 .expect("git diff");
150 assert!(
151 out.status.success() || out.status.code() == Some(1),
152 "git diff failed: {}",
153 String::from_utf8_lossy(&out.stderr)
154 );
155 let line = String::from_utf8_lossy(&out.stdout);
156 let line = line.lines().next().unwrap_or("");
157 let mut parts = line.split('\t');
158 let ins = parts.next().unwrap_or("0").parse().unwrap_or(0);
159 let del = parts.next().unwrap_or("0").parse().unwrap_or(0);
160 LineStat {
161 insertions: ins,
162 deletions: del,
163 }
164 }
165
166 #[test]
167 fn myers_stat_preserves_line_boundaries_and_repeated_lines() {
168 let options = DiffOptions::default();
169 let texts = [
170 "",
171 "a",
172 "a\n",
173 "a\na",
174 "a\na\n",
175 "a\nb\na\n",
176 "b\na\nb\n",
177 "\n",
178 "\n\n",
179 "a\r",
180 "a\r\n",
181 "a\rb\n",
182 "é\n猫",
183 "é\n猫\n",
184 "a\n\r\nb\n",
185 ];
186 for old in texts {
187 for new in texts {
188 let grit = line_change_counts(old, new, &options);
189 let patch = patch_oracle_stat(old, new, &options);
190 assert_eq!(grit, patch, "patch oracle: old={old:?}, new={new:?}");
191 if !old.contains('\r') && !new.contains('\r') {
192 let git = git_numstat(old, new, DiffAlgorithm::Myers);
193 assert_eq!(grit, git, "git numstat: old={old:?}, new={new:?}");
194 }
195 }
196 }
197
198 let mut seed = 4687u64;
199 for iteration in 0..500 {
200 let mut text = || {
201 (0..160)
202 .map(|_| {
203 seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
204 match seed >> 61 {
205 0..=3 => "repeat\n",
206 4 => "é\r\n",
207 5 => "\n",
208 6 => "x\ry\n",
209 _ => "tail\n",
210 }
211 })
212 .collect::<String>()
213 };
214 let (mut old, mut new) = (text(), text());
215 if iteration % 2 == 0 {
216 old = old.replace('\r', "");
217 new = new.replace('\r', "");
218 }
219 let prefix = "unchanged\n".repeat(120);
220 let old = format!("{prefix}{old}{prefix}");
221 let new = format!("{prefix}{new}{prefix}");
222 let grit = line_change_counts(&old, &new, &options);
223 let patch = patch_oracle_stat(&old, &new, &options);
224 assert_eq!(grit, patch, "random iteration {iteration}");
225 }
226 }
227
228 #[test]
229 fn myers_stat_large_file_small_edit() {
230 let options = DiffOptions::default();
231 let prefix = "unchanged prefix\n".repeat(10_000);
232 let suffix = "unchanged suffix\n".repeat(10_000);
233 let old = format!("{prefix}old\n{suffix}");
234 let new = format!("{prefix}new\nextra\n{suffix}");
235 let grit = line_change_counts(&old, &new, &options);
236 assert_eq!(
237 grit,
238 LineStat {
239 insertions: 2,
240 deletions: 1,
241 }
242 );
243 assert_eq!(grit, patch_oracle_stat(&old, &new, &options));
244 assert_eq!(grit, git_numstat(&old, &new, DiffAlgorithm::Myers));
245 }
246
247 #[test]
248 fn myers_stat_repetitive_po_like_fixture_matches_git() {
249 let options = DiffOptions::default();
250 let repeated = "msgid \"entry\"\nmsgstr \"same translation line\"\n".repeat(15_000);
251 let mut old = repeated.clone();
252 old.push_str("msgid \"removed\"\nmsgstr \"gone\"\n");
253 let mut new = repeated;
254 new.push_str("msgid \"added-a\"\nmsgstr \"one\"\n");
255 new.push_str("msgid \"added-b\"\nmsgstr \"two\"\n");
256 let grit = line_change_counts(&old, &new, &options);
257 let git = git_numstat(&old, &new, DiffAlgorithm::Myers);
258 assert_eq!(grit, git, "repetitive .po-like numstat vs git");
259 assert_eq!(grit, patch_oracle_stat(&old, &new, &options));
260 }
261
262 #[test]
263 fn line_stat_matches_git_and_repeated_anchor_fixture() {
264 let anchor = "fn anchor() {\n";
265 let old = format!("{anchor}{anchor}// old body\n");
266 let mut new = String::new();
267 for i in 0..800 {
268 new.push_str(&format!("// inserted block {i}\n"));
269 }
270 new.push_str(&anchor);
271 new.push_str("// new tail\n");
272
273 for algorithm in [
274 DiffAlgorithm::Myers,
275 DiffAlgorithm::Minimal,
276 DiffAlgorithm::Patience,
277 DiffAlgorithm::Histogram,
278 ] {
279 let options = DiffOptions::default()
280 .with_algorithm(algorithm)
281 .with_indent_heuristic(true);
282 let grit = line_change_counts(&old, &new, &options);
283 let git = git_numstat(&old, &new, algorithm);
284 assert_eq!(grit, git, "{algorithm:?} numstat vs git");
285 }
286 }
287}