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