Skip to main content

gitu/
config.rs

1use std::{collections::BTreeMap, path::PathBuf};
2
3use crate::style::{Color, Modifier, Style};
4use crate::{Bindings, Res, error::Error, key_parser, menu::Menu, ops::Op};
5use crossterm::event::{KeyCode, KeyModifiers};
6use etcetera::{BaseStrategy, choose_base_strategy};
7use figment::{
8    Figment,
9    providers::{Format, Toml},
10};
11use serde::Deserialize;
12
13const DEFAULT_CONFIG: &str = include_str!("default_config.toml");
14
15pub struct Config {
16    /// The file the user's configuration is read from, whether or not it
17    /// exists, and so the one gitu writes to.
18    pub path: PathBuf,
19    pub general: GeneralConfig,
20    pub style: StyleConfig,
21    pub bindings: Bindings,
22    pub picker_bindings: PickerBindings,
23}
24
25#[derive(Default, Deserialize)]
26pub(crate) struct PickerBindingsConfig {
27    #[serde(default)]
28    pub next: Vec<String>,
29    #[serde(default)]
30    pub previous: Vec<String>,
31    #[serde(default)]
32    pub done: Vec<String>,
33    #[serde(default)]
34    pub cancel: Vec<String>,
35}
36
37#[derive(Default, Deserialize)]
38pub(crate) struct BindingsConfig {
39    #[serde(flatten)]
40    pub menus: BTreeMap<Menu, BTreeMap<Op, Vec<String>>>,
41    #[serde(default)]
42    pub picker: PickerBindingsConfig,
43}
44
45#[derive(Default, Deserialize)]
46/// Only used to deserialise configurations with `figment`. This should be
47/// parsed to be turned into a useful [`Config`].
48pub(crate) struct FigmentConfig {
49    pub general: GeneralConfig,
50    pub style: StyleConfig,
51    pub bindings: BindingsConfig,
52}
53
54#[derive(Default, Debug, Deserialize)]
55pub struct GeneralConfig {
56    pub always_show_help: BoolConfigEntry,
57    pub confirm_quit: BoolConfigEntry,
58    pub refresh_on_file_change: BoolConfigEntry,
59    pub confirm_discard: ConfirmDiscardOption,
60    pub collapsed_sections: Vec<String>,
61    pub stash_list_limit: usize,
62    pub recent_commits_limit: usize,
63    pub log_author_width: usize,
64    pub mouse_support: bool,
65    pub mouse_scroll_lines: usize,
66    /// The most input gitu will read, as a pager or when putting a command
67    /// again. It is read in one pass before anything is drawn, so this is what
68    /// stops a `git log -p` over a whole history from showing nothing for
69    /// minutes. What is past the limit is dropped, not deferred.
70    pub max_input_bytes: u64,
71    /// Whether the branch section uses git's wording - 'On branch {name}' over
72    /// a line placing it relative to its upstream - or the branch name alone.
73    pub verbose_branch_status: bool,
74    /// Globs whose files are dropped from a paged view, as if the patch never
75    /// had them. Generated code and fixtures recur, so hiding them is worth
76    /// saying once rather than per session.
77    #[serde(default)]
78    pub hide: Vec<String>,
79    /// Whether the cursor stops on a diff's unchanged lines, as reading a patch
80    /// line by line wants. Nothing can be staged there.
81    #[serde(default)]
82    pub visit_context_lines: bool,
83    /// Whether the status screen opens with the cursor on the first unstaged
84    /// file, or the first staged one when nothing is unstaged.
85    #[serde(default)]
86    pub status_cursor_on_changes: bool,
87    #[serde(default)]
88    pub diff_renderer: DiffRendererConfig,
89    /// Arguments given to the `git log` gitu runs for the log view.
90    #[serde(default)]
91    pub log_args: Vec<String>,
92    /// Arguments given to the `git show` gitu runs for a commit it opens.
93    #[serde(default)]
94    pub show_args: Vec<String>,
95    #[serde(default)]
96    pub file_header: FileHeader,
97    /// Whether a patch of more than one file that does not fit on one screen
98    /// starts with its files folded: `git diff`, `git show`, `git log -p`, and a
99    /// commit opened from the log.
100    #[serde(default)]
101    pub start_folded: bool,
102}
103
104/// What heads each file of a patch, folded or not.
105#[derive(Default, Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
106#[serde(rename_all = "lowercase")]
107pub enum FileHeader {
108    /// The renderer's file header.
109    #[default]
110    Delta,
111    /// The file's line of a diffstat, counted by gitu from the patch.
112    Stat,
113}
114
115#[derive(Default, Debug, Deserialize)]
116pub struct DiffRendererConfig {
117    /// Renderer command as `[executable, ...args]`. It receives a unified diff
118    /// on stdin and must emit ANSI-colored output that preserves line structure.
119    #[serde(default)]
120    pub command: Vec<String>,
121    /// Named renderer features offered by `renderer_features`, to turn on and
122    /// off while viewing a diff.
123    #[serde(default)]
124    pub features: Vec<String>,
125    /// Keys that toggle a feature from `renderer_features` before anything is
126    /// typed to filter it, by feature.
127    #[serde(default)]
128    pub feature_keys: BTreeMap<String, char>,
129}
130
131#[derive(Default, Debug, Deserialize)]
132pub struct BoolConfigEntry {
133    #[serde(default)]
134    pub enabled: bool,
135}
136
137#[derive(Default, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
138#[serde(rename_all = "lowercase")]
139pub enum ConfirmDiscardOption {
140    #[default]
141    Line,
142    Hunk,
143    File,
144    Never,
145}
146
147#[derive(Default, Debug, Deserialize)]
148pub struct StyleConfig {
149    pub separator: StyleConfigEntry,
150
151    pub info_msg: StyleConfigEntry,
152    pub error_msg: StyleConfigEntry,
153    pub command: StyleConfigEntry,
154
155    #[serde(default)]
156    pub menu: MenuStyleConfig,
157
158    pub prompt: StyleConfigEntry,
159
160    pub section_header: StyleConfigEntry,
161    pub file_header: StyleConfigEntry,
162    pub hunk_header: StyleConfigEntry,
163
164    #[serde(default)]
165    pub diff_highlight: DiffHighlightConfig,
166
167    #[serde(default)]
168    pub syntax_highlight: SyntaxHighlightConfig,
169
170    #[serde(default)]
171    pub picker: PickerStyleConfig,
172
173    pub cursor: SymbolStyleConfigEntry,
174    pub selection_bar: SymbolStyleConfigEntry,
175    pub selection_line: StyleConfigEntry,
176    pub selection_area: StyleConfigEntry,
177
178    #[serde(default)]
179    pub search_match: StyleConfigEntry,
180
181    pub hash: StyleConfigEntry,
182    /// The instruction keywords in the interactive rebase todo view.
183    #[serde(default)]
184    pub rebase_todo: RebaseTodoStyleConfig,
185    pub branch: StyleConfigEntry,
186    pub remote: StyleConfigEntry,
187    pub tag: StyleConfigEntry,
188    pub author: StyleConfigEntry,
189    pub age: StyleConfigEntry,
190
191    #[serde(default)]
192    pub blame: BlameStyleConfig,
193}
194
195#[derive(Default, Debug, Deserialize)]
196pub struct RebaseTodoStyleConfig {
197    #[serde(default)]
198    pub reword: StyleConfigEntry,
199    #[serde(default)]
200    pub edit: StyleConfigEntry,
201    #[serde(default)]
202    pub squash: StyleConfigEntry,
203    #[serde(default)]
204    pub fixup: StyleConfigEntry,
205    #[serde(default)]
206    pub drop: StyleConfigEntry,
207}
208
209#[derive(Default, Debug, Deserialize)]
210pub struct BlameStyleConfig {
211    #[serde(default)]
212    pub line_num: StyleConfigEntry,
213    #[serde(default)]
214    pub code_line: StyleConfigEntry,
215}
216
217#[derive(Default, Debug, Deserialize)]
218pub struct MenuStyleConfig {
219    #[serde(default)]
220    pub heading: StyleConfigEntry,
221    #[serde(default)]
222    pub key: StyleConfigEntry,
223    /// Active argument value display (e.g., "--interactive")
224    #[serde(default)]
225    pub active_arg: StyleConfigEntry,
226    /// Inactive argument value display
227    #[serde(default)]
228    pub inactive_arg: StyleConfigEntry,
229}
230
231#[derive(Default, Debug, Deserialize)]
232pub struct DiffHighlightConfig {
233    #[serde(default)]
234    pub tag_old: StyleConfigEntry,
235    #[serde(default)]
236    pub tag_new: StyleConfigEntry,
237    #[serde(default)]
238    pub unchanged_old: StyleConfigEntry,
239    #[serde(default)]
240    pub unchanged_new: StyleConfigEntry,
241    #[serde(default)]
242    pub changed_old: StyleConfigEntry,
243    #[serde(default)]
244    pub changed_new: StyleConfigEntry,
245}
246
247#[derive(Default, Debug, Deserialize)]
248pub struct SyntaxHighlightConfig {
249    #[serde(default)]
250    pub enabled: bool,
251    #[serde(default)]
252    pub attribute: StyleConfigEntry,
253    #[serde(default)]
254    pub comment: StyleConfigEntry,
255    #[serde(default)]
256    pub constant_builtin: StyleConfigEntry,
257    #[serde(default)]
258    pub constant: StyleConfigEntry,
259    #[serde(default)]
260    pub constructor: StyleConfigEntry,
261    #[serde(default)]
262    pub embedded: StyleConfigEntry,
263    #[serde(default)]
264    pub function_builtin: StyleConfigEntry,
265    #[serde(default)]
266    pub function: StyleConfigEntry,
267    #[serde(default)]
268    pub keyword: StyleConfigEntry,
269    #[serde(default)]
270    pub number: StyleConfigEntry,
271    #[serde(default)]
272    pub module: StyleConfigEntry,
273    #[serde(default)]
274    pub property: StyleConfigEntry,
275    #[serde(default)]
276    pub operator: StyleConfigEntry,
277    #[serde(default)]
278    pub punctuation_bracket: StyleConfigEntry,
279    #[serde(default)]
280    pub punctuation_delimiter: StyleConfigEntry,
281    #[serde(default)]
282    pub string_special: StyleConfigEntry,
283    #[serde(default)]
284    pub string: StyleConfigEntry,
285    #[serde(default)]
286    pub tag: StyleConfigEntry,
287    #[serde(default)]
288    #[serde(rename = "type")]
289    pub type_regular: StyleConfigEntry,
290    #[serde(default)]
291    pub type_builtin: StyleConfigEntry,
292    #[serde(default)]
293    pub variable_builtin: StyleConfigEntry,
294    #[serde(default)]
295    pub variable_parameter: StyleConfigEntry,
296}
297
298#[derive(Default, Debug, Deserialize)]
299pub struct PickerStyleConfig {
300    #[serde(default)]
301    pub prompt: StyleConfigEntry,
302    #[serde(default)]
303    pub info: StyleConfigEntry,
304    #[serde(default)]
305    pub selection_line: StyleConfigEntry,
306    #[serde(default)]
307    pub matched: StyleConfigEntry,
308}
309
310#[derive(Default, Debug, Deserialize)]
311pub struct StyleConfigEntry {
312    #[serde(default)]
313    fg: Option<Color>,
314    #[serde(default)]
315    bg: Option<Color>,
316    #[serde(default)]
317    mods: Option<Modifier>,
318}
319
320#[derive(Default, Debug, Deserialize)]
321pub struct SymbolStyleConfigEntry {
322    #[serde(default)]
323    pub symbol: char,
324    #[serde(default)]
325    fg: Option<Color>,
326    #[serde(default)]
327    bg: Option<Color>,
328    #[serde(default)]
329    mods: Option<Modifier>,
330}
331
332impl From<&StyleConfigEntry> for Style {
333    fn from(val: &StyleConfigEntry) -> Self {
334        Style {
335            fg: val.fg,
336            bg: val.bg,
337            add_modifier: val.mods.unwrap_or(Modifier::empty()),
338            sub_modifier: Modifier::empty(),
339        }
340    }
341}
342
343impl From<&SymbolStyleConfigEntry> for Style {
344    fn from(val: &SymbolStyleConfigEntry) -> Self {
345        Style {
346            fg: val.fg,
347            bg: val.bg,
348            add_modifier: val.mods.unwrap_or(Modifier::empty()),
349            sub_modifier: Modifier::empty(),
350        }
351    }
352}
353
354pub struct PickerBindings {
355    pub next: Vec<Vec<(KeyModifiers, KeyCode)>>,
356    pub previous: Vec<Vec<(KeyModifiers, KeyCode)>>,
357    pub done: Vec<Vec<(KeyModifiers, KeyCode)>>,
358    pub cancel: Vec<Vec<(KeyModifiers, KeyCode)>>,
359}
360
361impl TryFrom<PickerBindingsConfig> for PickerBindings {
362    type Error = crate::error::Error;
363
364    fn try_from(config: PickerBindingsConfig) -> Result<Self, Self::Error> {
365        let mut bad_bindings = Vec::new();
366
367        let next = parse_picker_keys(&config.next, "picker.next", &mut bad_bindings);
368        let previous = parse_picker_keys(&config.previous, "picker.previous", &mut bad_bindings);
369        let done = parse_picker_keys(&config.done, "picker.done", &mut bad_bindings);
370        let cancel = parse_picker_keys(&config.cancel, "picker.cancel", &mut bad_bindings);
371
372        if !bad_bindings.is_empty() {
373            return Err(Error::Bindings {
374                bad_key_bindings: bad_bindings,
375            });
376        }
377
378        Ok(Self {
379            next,
380            previous,
381            done,
382            cancel,
383        })
384    }
385}
386
387fn parse_picker_keys(
388    raw_keys: &[String],
389    action_name: &str,
390    bad_bindings: &mut Vec<String>,
391) -> Vec<Vec<(KeyModifiers, KeyCode)>> {
392    raw_keys
393        .iter()
394        .filter_map(|keys| {
395            if let Ok(("", parsed)) = key_parser::parse_config_keys(keys) {
396                Some(parsed)
397            } else {
398                bad_bindings.push(format!("- {} = {}", action_name, keys));
399                None
400            }
401        })
402        .collect()
403}
404
405pub fn init_config(path: Option<PathBuf>) -> Res<Config> {
406    let config_path = path.unwrap_or_else(config_path);
407
408    if config_path.exists() {
409        log::info!("Loading config file at {config_path:?}");
410    } else {
411        log::info!("No config file at {config_path:?}");
412    }
413
414    let FigmentConfig {
415        general,
416        style,
417        bindings: bindings_config,
418    } = Figment::new()
419        .merge(Toml::string(DEFAULT_CONFIG))
420        .merge(Toml::file(&config_path))
421        .extract()
422        .map_err(Box::new)
423        .map_err(Error::Config)?;
424    let bindings = Bindings::try_from(bindings_config.menus)?;
425    let picker_bindings = PickerBindings::try_from(bindings_config.picker)?;
426    check_feature_keys(&general.diff_renderer.feature_keys, &bindings)?;
427
428    Ok(Config {
429        path: config_path,
430        general,
431        style,
432        bindings,
433        picker_bindings,
434    })
435}
436
437/// A feature's key is a top-level key, so one that is bound already would do two
438/// things.
439fn check_feature_keys(feature_keys: &BTreeMap<String, char>, bindings: &Bindings) -> Res<()> {
440    let taken: Vec<String> = feature_keys
441        .iter()
442        .filter(|(_, key)| bindings.binds_at_top_level(**key))
443        .map(|(feature, key)| {
444            format!("- general.diff_renderer.feature_keys.{feature} = {key} (bound already)")
445        })
446        .collect();
447    if !taken.is_empty() {
448        return Err(Error::Bindings {
449            bad_key_bindings: taken,
450        });
451    }
452    Ok(())
453}
454
455pub fn config_path() -> PathBuf {
456    choose_base_strategy()
457        .expect("Unable to find the config directory!")
458        .config_dir()
459        .join("gitu/config.toml")
460}
461
462#[cfg(test)]
463pub(crate) const TEST_SEARCH_HIGHLIGHT_BG: Color = Color::LightYellow;
464
465#[cfg(test)]
466pub(crate) fn init_test_config() -> Res<Config> {
467    let FigmentConfig {
468        mut general,
469        mut style,
470        bindings: bindings_config,
471    } = Figment::new()
472        .merge(Toml::string(DEFAULT_CONFIG))
473        .extract()
474        .map_err(Box::new)
475        .map_err(Error::Config)?;
476
477    general.always_show_help.enabled = false;
478    general.refresh_on_file_change.enabled = false;
479    general.status_cursor_on_changes = false;
480
481    style.search_match = StyleConfigEntry {
482        fg: None,
483        bg: Some(TEST_SEARCH_HIGHLIGHT_BG),
484        mods: None,
485    };
486
487    Ok(Config {
488        // A test that writes config says where; one that doesn't must not
489        // write the user's.
490        path: PathBuf::from("/nonexistent/gitu/config.toml"),
491        general,
492        style,
493        bindings: Bindings::try_from(bindings_config.menus).unwrap(),
494        picker_bindings: PickerBindings::try_from(bindings_config.picker).unwrap(),
495    })
496}
497
498#[cfg(test)]
499mod tests {
500    use crate::style::Color;
501    use figment::{
502        Figment,
503        providers::{Format, Toml},
504    };
505
506    use super::{DEFAULT_CONFIG, FigmentConfig};
507
508    #[test]
509    fn config_merges() {
510        let config: FigmentConfig = Figment::new()
511            .merge(Toml::string(DEFAULT_CONFIG))
512            .merge(Toml::string(
513                r#"
514                [style]
515                hunk_header.bg = "light green"
516                "#,
517            ))
518            .extract()
519            .unwrap();
520
521        assert_eq!(config.style.hunk_header.bg, Some(Color::LightGreen));
522        assert_eq!(config.style.hunk_header.fg, Some(Color::Blue));
523    }
524}