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 (returned by [`build_file_diffs`](crate::diff::build_file_diffs)).
70///
71/// Re-exported at the crate root as [`ListedFileDiff`](crate::diff::ListedFileDiff). Use
72/// [`crate::diff::targets::FileDiff`] when you need structured hunks from [`Diff::files`](crate::diff::Diff::files).
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct FileDiff {
75    /// Display path (new side, or old for deletions).
76    pub path: String,
77    /// Pre-rename path when it differs from [`Self::path`].
78    pub old_path: Option<String>,
79    /// Change kind.
80    pub status: DiffStatus,
81    /// Old git mode (octal), when present.
82    pub old_mode: Option<String>,
83    /// New git mode (octal), when present.
84    pub new_mode: Option<String>,
85    /// Either side looks like a binary blob.
86    pub binary: bool,
87    /// Old-side byte length (for binary `--stat` and JSON).
88    pub old_size: u64,
89    /// New-side byte length (for binary `--stat` and JSON).
90    pub new_size: u64,
91    /// Rename/copy similarity (0–100), when applicable.
92    pub similarity: Option<u32>,
93    /// Line counts when [`DiffDetail::Stat`] was requested.
94    pub stat: Option<LineStat>,
95    /// Structured hunks when [`DiffDetail::Patch`] was requested.
96    pub structured: Option<StructuredFileDiff>,
97    /// Combined (`--cc`) hunks for merge commits; mutually exclusive with [`Self::structured`].
98    pub combined: Option<crate::combined_diff_patch::CombinedStructuredTextDiff>,
99    /// Per-parent status letters for combined merge name-status (e.g. `MM`, `DD`).
100    pub combined_status: Option<String>,
101}
102
103impl DiffEntry {
104    /// Similarity index for renames and copies (`DiffEntry::score`).
105    #[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}