Skip to main content

grit_lib/diff/
detail.rs

1//! Per-file diff detail levels (patch, stat, name-only) and aggregate totals.
2
3use super::options::DiffOptions;
4use super::structured::StructuredFileDiff;
5use super::{count_line_changes_with_options, DiffEntry, DiffStatus};
6
7/// Count insertions and deletions between two text sides using [`DiffOptions`]'s algorithm.
8///
9/// Does not allocate hunk structures (used for `--stat` / `--numstat`). Counts follow the
10/// same compacted line alignment as structured patches for the same [`DiffOptions`].
11#[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/// How much content to compute for each changed file.
22#[non_exhaustive]
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum DiffDetail {
25    /// Full structured line hunks.
26    #[default]
27    Patch,
28    /// Insertion/deletion counts only (`--stat`, `--numstat`, `--shortstat`).
29    Stat,
30    /// `git diff --name-status` (status letter and paths).
31    NameStatus,
32    /// `git diff --name-only` (paths only).
33    NameOnly,
34}
35
36/// Line insertion/deletion counts for one file (`--numstat`).
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub struct LineStat {
39    /// Lines added on the new side.
40    pub insertions: usize,
41    /// Lines removed from the old side.
42    pub deletions: usize,
43}
44
45/// Summary across all files in a stat-style diff.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub struct DiffTotals {
48    /// Number of changed paths.
49    pub files_changed: usize,
50    /// Total insertions across text files.
51    pub insertions: usize,
52    /// Total deletions across text files.
53    pub deletions: usize,
54}
55
56impl DiffTotals {
57    /// Sum per-file stats into totals (binary files contribute to `files_changed` only).
58    #[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/// One changed file with optional patch body or line counts.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct FileDiff {
72    /// Display path (new side, or old for deletions).
73    pub path: String,
74    /// Pre-rename path when it differs from [`Self::path`].
75    pub old_path: Option<String>,
76    /// Change kind.
77    pub status: DiffStatus,
78    /// Old git mode (octal), when present.
79    pub old_mode: Option<String>,
80    /// New git mode (octal), when present.
81    pub new_mode: Option<String>,
82    /// Either side looks like a binary blob.
83    pub binary: bool,
84    /// Rename/copy similarity (0–100), when applicable.
85    pub similarity: Option<u32>,
86    /// Line counts when [`DiffDetail::Stat`] was requested.
87    pub stat: Option<LineStat>,
88    /// Structured hunks when [`DiffDetail::Patch`] was requested.
89    pub structured: Option<StructuredFileDiff>,
90    /// Combined (`--cc`) hunks for merge commits; mutually exclusive with [`Self::structured`].
91    pub combined: Option<crate::combined_diff_patch::CombinedStructuredTextDiff>,
92    /// Per-parent status letters for combined merge name-status (e.g. `MM`, `DD`).
93    pub combined_status: Option<String>,
94}
95
96impl DiffEntry {
97    /// Similarity index for renames and copies (`DiffEntry::score`).
98    #[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}