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