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 applied by [`super::build_file_diffs`] and tree content loaders when
108    /// entries are not already paired (default similarity 50 via [`RenameDetection::Similarity`], Git `-M`).
109    pub renames: RenameDetection,
110    /// `--diff-filter` status selection (applied after rename detection).
111    pub filter: Option<DiffFilter>,
112    /// Whitespace comparison rules for line diffs.
113    pub whitespace: WhitespaceMode,
114    /// When false, skip intra-line word emphasis (line-level hunks only).
115    pub word_level_inline: bool,
116    /// Expand hunks to enclosing function boundaries (`git diff -W`).
117    pub function_context: bool,
118    /// When set, incremented on each blob ODB read during tree content loading (tests only).
119    #[doc(hidden)]
120    pub blob_read_counter: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
121    /// For [`super::diff_paths`] only: count lines via [`super::line_change_counts`] without hunks.
122    pub(crate) no_index_stat_only: bool,
123}
124
125impl Default for DiffOptions {
126    fn default() -> Self {
127        Self {
128            context_lines: 3,
129            inter_hunk_context: 0,
130            algorithm: DiffAlgorithm::Myers,
131            indent_heuristic: true,
132            renames: RenameDetection::default(),
133            filter: None,
134            whitespace: WhitespaceMode::default(),
135            word_level_inline: true,
136            function_context: false,
137            blob_read_counter: None,
138            no_index_stat_only: false,
139        }
140    }
141}
142
143impl PartialEq for DiffOptions {
144    fn eq(&self, other: &Self) -> bool {
145        self.context_lines == other.context_lines
146            && self.inter_hunk_context == other.inter_hunk_context
147            && self.algorithm == other.algorithm
148            && self.indent_heuristic == other.indent_heuristic
149            && self.renames == other.renames
150            && self.filter == other.filter
151            && self.whitespace == other.whitespace
152            && self.word_level_inline == other.word_level_inline
153            && self.function_context == other.function_context
154            && self.no_index_stat_only == other.no_index_stat_only
155    }
156}
157
158impl DiffOptions {
159    /// Builder-style update of context lines (`-U`).
160    #[must_use]
161    pub fn with_context_lines(mut self, context_lines: usize) -> Self {
162        self.context_lines = context_lines;
163        self
164    }
165
166    /// Builder-style update of inter-hunk context.
167    #[must_use]
168    pub fn with_inter_hunk_context(mut self, inter_hunk_context: usize) -> Self {
169        self.inter_hunk_context = inter_hunk_context;
170        self
171    }
172
173    /// Builder-style update of the line diff algorithm.
174    #[must_use]
175    pub fn with_algorithm(mut self, algorithm: DiffAlgorithm) -> Self {
176        self.algorithm = algorithm;
177        self
178    }
179
180    /// Builder-style update of the indent heuristic flag.
181    #[must_use]
182    pub fn with_indent_heuristic(mut self, indent_heuristic: bool) -> Self {
183        self.indent_heuristic = indent_heuristic;
184        self
185    }
186
187    /// Builder-style update of rename detection.
188    #[must_use]
189    pub fn with_renames(mut self, renames: RenameDetection) -> Self {
190        self.renames = renames;
191        self
192    }
193
194    /// Builder-style update of diff status filter.
195    #[must_use]
196    pub fn with_filter(mut self, filter: Option<DiffFilter>) -> Self {
197        self.filter = filter;
198        self
199    }
200
201    /// Similarity percent for rename detection, or `None` when [`RenameDetection::Off`].
202    #[must_use]
203    pub fn rename_similarity_threshold(&self) -> Option<u32> {
204        match self.renames {
205            RenameDetection::Off => None,
206            RenameDetection::Similarity(p) => Some(u32::from(p)),
207        }
208    }
209
210    /// u32 threshold for [`super::apply_rename_detection_to_diff_entries`] and related APIs.
211    #[must_use]
212    pub fn rename_threshold_u32(&self) -> u32 {
213        self.rename_similarity_threshold()
214            .unwrap_or(RENAME_DETECTION_DISABLED)
215    }
216
217    /// Builder-style update of word-level inline emphasis.
218    #[must_use]
219    pub fn with_word_level_inline(mut self, word_level_inline: bool) -> Self {
220        self.word_level_inline = word_level_inline;
221        self
222    }
223
224    /// Builder-style update of whitespace comparison rules.
225    #[must_use]
226    pub fn with_whitespace(mut self, whitespace: WhitespaceMode) -> Self {
227        self.whitespace = whitespace;
228        self
229    }
230
231    /// When true, [`super::diff_paths`] uses [`super::line_change_counts`] instead of building hunks.
232    #[must_use]
233    pub fn with_no_index_stat_only(mut self, stat_only: bool) -> Self {
234        self.no_index_stat_only = stat_only;
235        self
236    }
237
238    /// Turn off word-level inline when `changed_files` exceeds
239    /// [`WORD_LEVEL_INLINE_MAX_CHANGED_FILES`].
240    #[must_use]
241    pub fn cap_word_level_inline_for_changed_files(self, changed_files: usize) -> Self {
242        let mut opts = self;
243        if opts.word_level_inline && changed_files > WORD_LEVEL_INLINE_MAX_CHANGED_FILES {
244            opts.word_level_inline = false;
245        }
246        opts
247    }
248
249    /// Read `diff.algorithm`, `diff.context`, `diff.interHunkContext`, `diff.indentHeuristic`,
250    /// and `diff.renames` from repository config.
251    #[must_use]
252    pub fn from_config(config: &ConfigSet) -> Self {
253        let mut opts = Self::default();
254        if let Some(name) = config.get("diff.algorithm") {
255            if let Ok(parsed) = name.parse::<DiffAlgorithm>() {
256                opts.algorithm = parsed;
257            }
258        }
259        if let Some(v) = config.get("diff.context") {
260            if let Ok(n) = v.parse::<usize>() {
261                opts.context_lines = n;
262            }
263        }
264        if let Some(v) = config.get("diff.interHunkContext") {
265            if let Ok(n) = v.parse::<usize>() {
266                opts.inter_hunk_context = n;
267            }
268        }
269        opts.indent_heuristic = indent_heuristic_from_config(config);
270        opts.renames = renames_from_config(config);
271        if config
272            .get_bool("diff.ignoreSpaceChange")
273            .and_then(|r| r.ok())
274            .unwrap_or(false)
275        {
276            opts.whitespace.ignore_space_change = true;
277        }
278        opts
279    }
280
281    /// Resolve indent heuristic with optional CLI overrides (log/diff plumbing).
282    #[must_use]
283    pub fn resolve_indent_heuristic(
284        config: &ConfigSet,
285        cli_indent_heuristic: bool,
286        cli_no_indent_heuristic: bool,
287    ) -> bool {
288        resolve_indent_heuristic(config, cli_indent_heuristic, cli_no_indent_heuristic)
289    }
290}
291
292/// `diff.renames`: `false` disables; `true` or copy modes use default similarity.
293#[must_use]
294pub fn renames_from_config(config: &ConfigSet) -> RenameDetection {
295    match config.get("diff.renames") {
296        Some(v) => {
297            let v = v.trim();
298            if v.eq_ignore_ascii_case("false")
299                || v.eq_ignore_ascii_case("no")
300                || v.eq_ignore_ascii_case("off")
301                || v == "0"
302            {
303                RenameDetection::Off
304            } else {
305                RenameDetection::default()
306            }
307        }
308        None => RenameDetection::default(),
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::config::{ConfigFile, ConfigScope, ConfigSet};
316
317    fn config_from(snippet: &str) -> ConfigSet {
318        let file = ConfigFile::parse(std::path::Path::new("test"), snippet, ConfigScope::Local)
319            .expect("parse");
320        let mut set = ConfigSet::new();
321        set.merge(&file);
322        set
323    }
324
325    #[test]
326    fn diff_algorithm_from_str_roundtrip_and_error() {
327        for (s, expected) in [
328            ("myers", DiffAlgorithm::Myers),
329            ("default", DiffAlgorithm::Myers),
330            ("MYERS", DiffAlgorithm::Myers),
331            ("minimal", DiffAlgorithm::Minimal),
332            ("patience", DiffAlgorithm::Patience),
333            ("histogram", DiffAlgorithm::Histogram),
334        ] {
335            assert_eq!(s.parse::<DiffAlgorithm>().expect("parse"), expected);
336        }
337        assert!(matches!(
338            "bogus".parse::<DiffAlgorithm>(),
339            Err(UnknownDiffAlgorithm(_))
340        ));
341    }
342
343    #[test]
344    fn diff_options_from_config_reads_algorithm_context_renames() {
345        let cfg = config_from(
346            "[diff]\n\
347             \talgorithm = patience\n\
348             \tcontext = 5\n\
349             \tinterHunkContext = 2\n\
350             \tindentHeuristic = false\n\
351             \trenames = false\n",
352        );
353        let opts = DiffOptions::from_config(&cfg);
354        assert_eq!(opts.algorithm, DiffAlgorithm::Patience);
355        assert_eq!(opts.context_lines, 5);
356        assert_eq!(opts.inter_hunk_context, 2);
357        assert!(!opts.indent_heuristic);
358        assert_eq!(opts.renames, RenameDetection::Off);
359
360        for on in [
361            "",
362            "[diff]\n\trenames = true\n",
363            "[diff]\n\trenames = copies\n",
364        ] {
365            assert_eq!(
366                DiffOptions::from_config(&config_from(on)).renames,
367                RenameDetection::Similarity(50),
368                "config {on:?}"
369            );
370        }
371        assert_eq!(
372            DiffOptions::default().renames,
373            RenameDetection::Similarity(50)
374        );
375
376        for off in ["no", "0", "off", "OFF"] {
377            let cfg = config_from(&format!("[diff]\n\trenames = {off}\n"));
378            assert_eq!(
379                DiffOptions::from_config(&cfg).renames,
380                RenameDetection::Off,
381                "renames={off}"
382            );
383        }
384    }
385
386    #[test]
387    fn cap_word_level_inline_for_changed_files() {
388        let on = DiffOptions {
389            word_level_inline: true,
390            ..DiffOptions::default()
391        };
392        assert!(
393            on.clone()
394                .cap_word_level_inline_for_changed_files(WORD_LEVEL_INLINE_MAX_CHANGED_FILES)
395                .word_level_inline
396        );
397        assert!(
398            !on.cap_word_level_inline_for_changed_files(WORD_LEVEL_INLINE_MAX_CHANGED_FILES + 1)
399                .word_level_inline
400        );
401    }
402}