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