Skip to main content

grit_lib/diff/
options.rs

1//! Shared line-diff options for structured hunks, log patches, and tree diffs.
2
3use crate::config::ConfigSet;
4
5use super::filter::DiffFilter;
6use super::{indent_heuristic_from_config, resolve_indent_heuristic, WhitespaceMode};
7
8/// Line diff algorithm names matching Git's `diff.algorithm` and `--diff-algorithm`.
9#[non_exhaustive]
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum DiffAlgorithm {
12    /// Git default (`myers` / `default`).
13    #[default]
14    Myers,
15    /// Minimal diff (`minimal`).
16    Minimal,
17    /// Patience diff (`patience`).
18    Patience,
19    /// Histogram diff (`histogram`).
20    Histogram,
21}
22
23/// Unknown value for [`DiffAlgorithm`](DiffAlgorithm)'s [`FromStr`](std::str::FromStr) implementation.
24#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
25#[error("unknown diff algorithm `{0}`")]
26pub struct UnknownDiffAlgorithm(pub String);
27
28impl std::str::FromStr for DiffAlgorithm {
29    type Err = UnknownDiffAlgorithm;
30
31    fn from_str(s: &str) -> Result<Self, Self::Err> {
32        match s.to_ascii_lowercase().as_str() {
33            "myers" | "default" => Ok(Self::Myers),
34            "minimal" => Ok(Self::Minimal),
35            "patience" => Ok(Self::Patience),
36            "histogram" => Ok(Self::Histogram),
37            other => Err(UnknownDiffAlgorithm(other.to_owned())),
38        }
39    }
40}
41
42impl DiffAlgorithm {
43    /// `similar` crate algorithm for non-histogram engines.
44    #[must_use]
45    pub fn similar_algorithm(self) -> similar::Algorithm {
46        match self {
47            Self::Myers | Self::Histogram => similar::Algorithm::Myers,
48            Self::Minimal => similar::Algorithm::Lcs,
49            Self::Patience => similar::Algorithm::Patience,
50        }
51    }
52
53    /// Whether this selection uses the histogram line-diff engine.
54    #[must_use]
55    pub fn uses_git_histogram(self) -> bool {
56        matches!(self, Self::Histogram)
57    }
58}
59
60/// u32 rename APIs: thresholds `0`–`100` run detection at that Git `-M` percent;
61/// [`RENAME_DETECTION_DISABLED`] (101) and above skip detection (legacy postprocess and `--no-renames` plumbing).
62pub const RENAME_DETECTION_DISABLED: u32 = 101;
63
64/// Rename detection policy for tree diffs.
65#[non_exhaustive]
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum RenameDetection {
68    /// Do not detect renames.
69    Off,
70    /// Detect renames when similarity is at least this percent (Git `-M` default 50).
71    Similarity(u8),
72}
73
74impl Default for RenameDetection {
75    /// 50% similarity, Git's default for `-M` and `diff.renames=true`.
76    fn default() -> Self {
77        Self::Similarity(50)
78    }
79}
80
81impl RenameDetection {
82    /// Git rename threshold, or `None` when renames are disabled.
83    #[must_use]
84    pub fn threshold_percent(self) -> Option<u32> {
85        match self {
86            Self::Off => None,
87            Self::Similarity(p) => Some(u32::from(p)),
88        }
89    }
90}
91
92/// Changed-file count above which word-level inline emphasis is skipped (human TTY diffs).
93pub const WORD_LEVEL_INLINE_MAX_CHANGED_FILES: usize = 64;
94
95/// Line-diff options shared by structured output, log patches, and benchmarks.
96#[non_exhaustive]
97#[derive(Debug, Clone)]
98pub struct DiffOptions {
99    /// Unchanged context lines around each change (Git `-U`, default 3).
100    pub context_lines: usize,
101    /// `--inter-hunk-context` (default 0).
102    pub inter_hunk_context: usize,
103    /// Line diff algorithm.
104    pub algorithm: DiffAlgorithm,
105    /// Apply Git's indent heuristic when generating hunks.
106    pub indent_heuristic: bool,
107    /// Rename detection for tree diffs.
108    pub renames: RenameDetection,
109    /// `--diff-filter` status selection (applied after rename detection).
110    pub filter: Option<DiffFilter>,
111    /// Whitespace comparison rules for line diffs.
112    pub whitespace: WhitespaceMode,
113    /// When false, skip intra-line word emphasis (line-level hunks only).
114    pub word_level_inline: bool,
115    /// Expand hunks to enclosing function boundaries (`git diff -W`).
116    pub function_context: bool,
117    /// When set, incremented on each blob ODB read during tree content loading (tests only).
118    #[doc(hidden)]
119    pub blob_read_counter: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
120    /// For [`super::diff_paths`] only: count lines via [`super::line_change_counts`] without hunks.
121    pub(crate) no_index_stat_only: bool,
122}
123
124impl Default for DiffOptions {
125    fn default() -> Self {
126        Self {
127            context_lines: 3,
128            inter_hunk_context: 0,
129            algorithm: DiffAlgorithm::Myers,
130            indent_heuristic: true,
131            renames: RenameDetection::default(),
132            filter: None,
133            whitespace: WhitespaceMode::default(),
134            word_level_inline: true,
135            function_context: false,
136            blob_read_counter: None,
137            no_index_stat_only: false,
138        }
139    }
140}
141
142impl PartialEq for DiffOptions {
143    fn eq(&self, other: &Self) -> bool {
144        self.context_lines == other.context_lines
145            && self.inter_hunk_context == other.inter_hunk_context
146            && self.algorithm == other.algorithm
147            && self.indent_heuristic == other.indent_heuristic
148            && self.renames == other.renames
149            && self.filter == other.filter
150            && self.whitespace == other.whitespace
151            && self.word_level_inline == other.word_level_inline
152            && self.function_context == other.function_context
153            && self.no_index_stat_only == other.no_index_stat_only
154    }
155}
156
157impl DiffOptions {
158    /// Builder-style update of context lines (`-U`).
159    #[must_use]
160    pub fn with_context_lines(mut self, context_lines: usize) -> Self {
161        self.context_lines = context_lines;
162        self
163    }
164
165    /// Builder-style update of inter-hunk context.
166    #[must_use]
167    pub fn with_inter_hunk_context(mut self, inter_hunk_context: usize) -> Self {
168        self.inter_hunk_context = inter_hunk_context;
169        self
170    }
171
172    /// Builder-style update of the line diff algorithm.
173    #[must_use]
174    pub fn with_algorithm(mut self, algorithm: DiffAlgorithm) -> Self {
175        self.algorithm = algorithm;
176        self
177    }
178
179    /// Builder-style update of the indent heuristic flag.
180    #[must_use]
181    pub fn with_indent_heuristic(mut self, indent_heuristic: bool) -> Self {
182        self.indent_heuristic = indent_heuristic;
183        self
184    }
185
186    /// Builder-style update of rename detection.
187    #[must_use]
188    pub fn with_renames(mut self, renames: RenameDetection) -> Self {
189        self.renames = renames;
190        self
191    }
192
193    /// Builder-style update of diff status filter.
194    #[must_use]
195    pub fn with_filter(mut self, filter: Option<DiffFilter>) -> Self {
196        self.filter = filter;
197        self
198    }
199
200    /// Similarity percent for rename detection, or `None` when [`RenameDetection::Off`].
201    #[must_use]
202    pub fn rename_similarity_threshold(&self) -> Option<u32> {
203        match self.renames {
204            RenameDetection::Off => None,
205            RenameDetection::Similarity(p) => Some(u32::from(p)),
206        }
207    }
208
209    /// u32 threshold for [`super::apply_rename_detection_to_diff_entries`] and related APIs.
210    #[must_use]
211    pub fn rename_threshold_u32(&self) -> u32 {
212        self.rename_similarity_threshold()
213            .unwrap_or(RENAME_DETECTION_DISABLED)
214    }
215
216    /// Builder-style update of word-level inline emphasis.
217    #[must_use]
218    pub fn with_word_level_inline(mut self, word_level_inline: bool) -> Self {
219        self.word_level_inline = word_level_inline;
220        self
221    }
222
223    /// Builder-style update of whitespace comparison rules.
224    #[must_use]
225    pub fn with_whitespace(mut self, whitespace: WhitespaceMode) -> Self {
226        self.whitespace = whitespace;
227        self
228    }
229
230    /// When true, [`super::diff_paths`] uses [`super::line_change_counts`] instead of building hunks.
231    #[must_use]
232    pub fn with_no_index_stat_only(mut self, stat_only: bool) -> Self {
233        self.no_index_stat_only = stat_only;
234        self
235    }
236
237    /// Turn off word-level inline when `changed_files` exceeds
238    /// [`WORD_LEVEL_INLINE_MAX_CHANGED_FILES`].
239    #[must_use]
240    pub fn cap_word_level_inline_for_changed_files(self, changed_files: usize) -> Self {
241        let mut opts = self;
242        if opts.word_level_inline && changed_files > WORD_LEVEL_INLINE_MAX_CHANGED_FILES {
243            opts.word_level_inline = false;
244        }
245        opts
246    }
247
248    /// Read `diff.algorithm`, `diff.context`, `diff.interHunkContext`, `diff.indentHeuristic`,
249    /// and `diff.renames` from repository config.
250    #[must_use]
251    pub fn from_config(config: &ConfigSet) -> Self {
252        let mut opts = Self::default();
253        if let Some(name) = config.get("diff.algorithm") {
254            if let Ok(parsed) = name.parse::<DiffAlgorithm>() {
255                opts.algorithm = parsed;
256            }
257        }
258        if let Some(v) = config.get("diff.context") {
259            if let Ok(n) = v.parse::<usize>() {
260                opts.context_lines = n;
261            }
262        }
263        if let Some(v) = config.get("diff.interHunkContext") {
264            if let Ok(n) = v.parse::<usize>() {
265                opts.inter_hunk_context = n;
266            }
267        }
268        opts.indent_heuristic = indent_heuristic_from_config(config);
269        opts.renames = renames_from_config(config);
270        if config
271            .get_bool("diff.ignoreSpaceChange")
272            .and_then(|r| r.ok())
273            .unwrap_or(false)
274        {
275            opts.whitespace.ignore_space_change = true;
276        }
277        opts
278    }
279
280    /// Resolve indent heuristic with optional CLI overrides (log/diff plumbing).
281    #[must_use]
282    pub fn resolve_indent_heuristic(
283        config: &ConfigSet,
284        cli_indent_heuristic: bool,
285        cli_no_indent_heuristic: bool,
286    ) -> bool {
287        resolve_indent_heuristic(config, cli_indent_heuristic, cli_no_indent_heuristic)
288    }
289}
290
291/// `diff.renames`: `false` disables; `true` or copy modes use default similarity.
292#[must_use]
293pub fn renames_from_config(config: &ConfigSet) -> RenameDetection {
294    match config.get("diff.renames") {
295        Some(v) => {
296            let v = v.trim();
297            if v.eq_ignore_ascii_case("false")
298                || v.eq_ignore_ascii_case("no")
299                || v.eq_ignore_ascii_case("off")
300                || v == "0"
301            {
302                RenameDetection::Off
303            } else {
304                RenameDetection::default()
305            }
306        }
307        None => RenameDetection::default(),
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::config::{ConfigFile, ConfigScope, ConfigSet};
315
316    fn config_from(snippet: &str) -> ConfigSet {
317        let file = ConfigFile::parse(std::path::Path::new("test"), snippet, ConfigScope::Local)
318            .expect("parse");
319        let mut set = ConfigSet::new();
320        set.merge(&file);
321        set
322    }
323
324    #[test]
325    fn diff_algorithm_from_str_roundtrip_and_error() {
326        for (s, expected) in [
327            ("myers", DiffAlgorithm::Myers),
328            ("default", DiffAlgorithm::Myers),
329            ("MYERS", DiffAlgorithm::Myers),
330            ("minimal", DiffAlgorithm::Minimal),
331            ("patience", DiffAlgorithm::Patience),
332            ("histogram", DiffAlgorithm::Histogram),
333        ] {
334            assert_eq!(s.parse::<DiffAlgorithm>().expect("parse"), expected);
335        }
336        assert!(matches!(
337            "bogus".parse::<DiffAlgorithm>(),
338            Err(UnknownDiffAlgorithm(_))
339        ));
340    }
341
342    #[test]
343    fn diff_options_from_config_reads_algorithm_context_renames() {
344        let cfg = config_from(
345            "[diff]\n\
346             \talgorithm = patience\n\
347             \tcontext = 5\n\
348             \tinterHunkContext = 2\n\
349             \tindentHeuristic = false\n\
350             \trenames = false\n",
351        );
352        let opts = DiffOptions::from_config(&cfg);
353        assert_eq!(opts.algorithm, DiffAlgorithm::Patience);
354        assert_eq!(opts.context_lines, 5);
355        assert_eq!(opts.inter_hunk_context, 2);
356        assert!(!opts.indent_heuristic);
357        assert_eq!(opts.renames, RenameDetection::Off);
358
359        for on in [
360            "",
361            "[diff]\n\trenames = true\n",
362            "[diff]\n\trenames = copies\n",
363        ] {
364            assert_eq!(
365                DiffOptions::from_config(&config_from(on)).renames,
366                RenameDetection::Similarity(50),
367                "config {on:?}"
368            );
369        }
370        assert_eq!(
371            DiffOptions::default().renames,
372            RenameDetection::Similarity(50)
373        );
374
375        for off in ["no", "0", "off", "OFF"] {
376            let cfg = config_from(&format!("[diff]\n\trenames = {off}\n"));
377            assert_eq!(
378                DiffOptions::from_config(&cfg).renames,
379                RenameDetection::Off,
380                "renames={off}"
381            );
382        }
383    }
384
385    #[test]
386    fn cap_word_level_inline_for_changed_files() {
387        let on = DiffOptions {
388            word_level_inline: true,
389            ..DiffOptions::default()
390        };
391        assert!(
392            on.clone()
393                .cap_word_level_inline_for_changed_files(WORD_LEVEL_INLINE_MAX_CHANGED_FILES)
394                .word_level_inline
395        );
396        assert!(
397            !on.cap_word_level_inline_for_changed_files(WORD_LEVEL_INLINE_MAX_CHANGED_FILES + 1)
398                .word_level_inline
399        );
400    }
401}