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]`, set by `delta --gitu` to
118    /// itself rather than read from config. It receives a unified diff on stdin
119    /// and emits ANSI-colored output carrying OSC-1717 diff-line metadata.
120    #[serde(skip)]
121    pub command: Vec<String>,
122    /// Named renderer features offered by `renderer_features`, to turn on and
123    /// off while viewing a diff.
124    #[serde(default)]
125    pub features: Vec<String>,
126    /// Keys that toggle a feature from `renderer_features` before anything is
127    /// typed to filter it, by feature.
128    #[serde(default)]
129    pub feature_keys: BTreeMap<String, char>,
130}
131
132#[derive(Default, Debug, Deserialize)]
133pub struct BoolConfigEntry {
134    #[serde(default)]
135    pub enabled: bool,
136}
137
138#[derive(Default, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
139#[serde(rename_all = "lowercase")]
140pub enum ConfirmDiscardOption {
141    #[default]
142    Line,
143    Hunk,
144    File,
145    Never,
146}
147
148#[derive(Default, Debug, Deserialize)]
149pub struct StyleConfig {
150    pub separator: StyleConfigEntry,
151
152    pub info_msg: StyleConfigEntry,
153    pub error_msg: StyleConfigEntry,
154    pub command: StyleConfigEntry,
155
156    #[serde(default)]
157    pub menu: MenuStyleConfig,
158
159    pub prompt: StyleConfigEntry,
160
161    pub section_header: StyleConfigEntry,
162    pub file_header: StyleConfigEntry,
163    pub hunk_header: StyleConfigEntry,
164
165    #[serde(default)]
166    pub picker: PickerStyleConfig,
167
168    pub cursor: SymbolStyleConfigEntry,
169    pub selection_bar: SymbolStyleConfigEntry,
170    pub selection_line: StyleConfigEntry,
171    pub selection_area: StyleConfigEntry,
172
173    #[serde(default)]
174    pub search_match: StyleConfigEntry,
175
176    pub hash: StyleConfigEntry,
177    /// The instruction keywords in the interactive rebase todo view.
178    #[serde(default)]
179    pub rebase_todo: RebaseTodoStyleConfig,
180    pub branch: StyleConfigEntry,
181    pub remote: StyleConfigEntry,
182    pub tag: StyleConfigEntry,
183    pub author: StyleConfigEntry,
184    pub age: StyleConfigEntry,
185
186    #[serde(default)]
187    pub blame: BlameStyleConfig,
188}
189
190#[derive(Default, Debug, Deserialize)]
191pub struct RebaseTodoStyleConfig {
192    #[serde(default)]
193    pub reword: StyleConfigEntry,
194    #[serde(default)]
195    pub edit: StyleConfigEntry,
196    #[serde(default)]
197    pub squash: StyleConfigEntry,
198    #[serde(default)]
199    pub fixup: StyleConfigEntry,
200    #[serde(default)]
201    pub drop: StyleConfigEntry,
202}
203
204#[derive(Default, Debug, Deserialize)]
205pub struct BlameStyleConfig {
206    #[serde(default)]
207    pub line_num: StyleConfigEntry,
208    #[serde(default)]
209    pub code_line: StyleConfigEntry,
210}
211
212#[derive(Default, Debug, Deserialize)]
213pub struct MenuStyleConfig {
214    #[serde(default)]
215    pub heading: StyleConfigEntry,
216    #[serde(default)]
217    pub key: StyleConfigEntry,
218    /// Active argument value display (e.g., "--interactive")
219    #[serde(default)]
220    pub active_arg: StyleConfigEntry,
221    /// Inactive argument value display
222    #[serde(default)]
223    pub inactive_arg: StyleConfigEntry,
224}
225
226#[derive(Default, Debug, Deserialize)]
227pub struct PickerStyleConfig {
228    #[serde(default)]
229    pub prompt: StyleConfigEntry,
230    #[serde(default)]
231    pub info: StyleConfigEntry,
232    #[serde(default)]
233    pub selection_line: StyleConfigEntry,
234    #[serde(default)]
235    pub matched: StyleConfigEntry,
236}
237
238#[derive(Default, Debug, Deserialize)]
239pub struct StyleConfigEntry {
240    #[serde(default)]
241    fg: Option<Color>,
242    #[serde(default)]
243    bg: Option<Color>,
244    #[serde(default)]
245    mods: Option<Modifier>,
246}
247
248#[derive(Default, Debug, Deserialize)]
249pub struct SymbolStyleConfigEntry {
250    #[serde(default)]
251    pub symbol: char,
252    #[serde(default)]
253    fg: Option<Color>,
254    #[serde(default)]
255    bg: Option<Color>,
256    #[serde(default)]
257    mods: Option<Modifier>,
258}
259
260impl From<&StyleConfigEntry> for Style {
261    fn from(val: &StyleConfigEntry) -> Self {
262        Style {
263            fg: val.fg,
264            bg: val.bg,
265            add_modifier: val.mods.unwrap_or(Modifier::empty()),
266            sub_modifier: Modifier::empty(),
267        }
268    }
269}
270
271impl From<&SymbolStyleConfigEntry> for Style {
272    fn from(val: &SymbolStyleConfigEntry) -> Self {
273        Style {
274            fg: val.fg,
275            bg: val.bg,
276            add_modifier: val.mods.unwrap_or(Modifier::empty()),
277            sub_modifier: Modifier::empty(),
278        }
279    }
280}
281
282pub struct PickerBindings {
283    pub next: Vec<Vec<(KeyModifiers, KeyCode)>>,
284    pub previous: Vec<Vec<(KeyModifiers, KeyCode)>>,
285    pub done: Vec<Vec<(KeyModifiers, KeyCode)>>,
286    pub cancel: Vec<Vec<(KeyModifiers, KeyCode)>>,
287}
288
289impl TryFrom<PickerBindingsConfig> for PickerBindings {
290    type Error = crate::error::Error;
291
292    fn try_from(config: PickerBindingsConfig) -> Result<Self, Self::Error> {
293        let mut bad_bindings = Vec::new();
294
295        let next = parse_picker_keys(&config.next, "picker.next", &mut bad_bindings);
296        let previous = parse_picker_keys(&config.previous, "picker.previous", &mut bad_bindings);
297        let done = parse_picker_keys(&config.done, "picker.done", &mut bad_bindings);
298        let cancel = parse_picker_keys(&config.cancel, "picker.cancel", &mut bad_bindings);
299
300        if !bad_bindings.is_empty() {
301            return Err(Error::Bindings {
302                bad_key_bindings: bad_bindings,
303            });
304        }
305
306        Ok(Self {
307            next,
308            previous,
309            done,
310            cancel,
311        })
312    }
313}
314
315fn parse_picker_keys(
316    raw_keys: &[String],
317    action_name: &str,
318    bad_bindings: &mut Vec<String>,
319) -> Vec<Vec<(KeyModifiers, KeyCode)>> {
320    raw_keys
321        .iter()
322        .filter_map(|keys| {
323            if let Ok(("", parsed)) = key_parser::parse_config_keys(keys) {
324                Some(parsed)
325            } else {
326                bad_bindings.push(format!("- {} = {}", action_name, keys));
327                None
328            }
329        })
330        .collect()
331}
332
333pub fn init_config(path: Option<PathBuf>) -> Res<Config> {
334    let config_path = path.unwrap_or_else(config_path);
335
336    if config_path.exists() {
337        log::info!("Loading config file at {config_path:?}");
338    } else {
339        log::info!("No config file at {config_path:?}");
340    }
341
342    let FigmentConfig {
343        general,
344        style,
345        bindings: bindings_config,
346    } = Figment::new()
347        .merge(Toml::string(DEFAULT_CONFIG))
348        .merge(Toml::file(&config_path))
349        .extract()
350        .map_err(Box::new)
351        .map_err(Error::Config)?;
352    let bindings = Bindings::try_from(bindings_config.menus)?;
353    let picker_bindings = PickerBindings::try_from(bindings_config.picker)?;
354    check_feature_keys(&general.diff_renderer.feature_keys, &bindings)?;
355
356    Ok(Config {
357        path: config_path,
358        general,
359        style,
360        bindings,
361        picker_bindings,
362    })
363}
364
365/// A feature's key is a top-level key, so one that is bound already would do two
366/// things.
367fn check_feature_keys(feature_keys: &BTreeMap<String, char>, bindings: &Bindings) -> Res<()> {
368    let taken: Vec<String> = feature_keys
369        .iter()
370        .filter(|(_, key)| bindings.binds_at_top_level(**key))
371        .map(|(feature, key)| {
372            format!("- general.diff_renderer.feature_keys.{feature} = {key} (bound already)")
373        })
374        .collect();
375    if !taken.is_empty() {
376        return Err(Error::Bindings {
377            bad_key_bindings: taken,
378        });
379    }
380    Ok(())
381}
382
383pub fn config_path() -> PathBuf {
384    choose_base_strategy()
385        .expect("Unable to find the config directory!")
386        .config_dir()
387        .join("gitu/config.toml")
388}
389
390#[cfg(test)]
391pub(crate) const TEST_SEARCH_HIGHLIGHT_BG: Color = Color::LightYellow;
392
393#[cfg(test)]
394pub(crate) fn init_test_config() -> Res<Config> {
395    let FigmentConfig {
396        mut general,
397        mut style,
398        bindings: bindings_config,
399    } = Figment::new()
400        .merge(Toml::string(DEFAULT_CONFIG))
401        .extract()
402        .map_err(Box::new)
403        .map_err(Error::Config)?;
404
405    general.always_show_help.enabled = false;
406    general.refresh_on_file_change.enabled = false;
407    general.status_cursor_on_changes = false;
408    general.diff_renderer.command = crate::tests::helpers::ui::renderer();
409
410    style.search_match = StyleConfigEntry {
411        fg: None,
412        bg: Some(TEST_SEARCH_HIGHLIGHT_BG),
413        mods: None,
414    };
415
416    Ok(Config {
417        // A test that writes config says where; one that doesn't must not
418        // write the user's.
419        path: PathBuf::from("/nonexistent/gitu/config.toml"),
420        general,
421        style,
422        bindings: Bindings::try_from(bindings_config.menus).unwrap(),
423        picker_bindings: PickerBindings::try_from(bindings_config.picker).unwrap(),
424    })
425}
426
427#[cfg(test)]
428mod tests {
429    use crate::style::Color;
430    use figment::{
431        Figment,
432        providers::{Format, Toml},
433    };
434
435    use super::{DEFAULT_CONFIG, FigmentConfig};
436
437    #[test]
438    fn config_merges() {
439        let config: FigmentConfig = Figment::new()
440            .merge(Toml::string(DEFAULT_CONFIG))
441            .merge(Toml::string(
442                r#"
443                [style]
444                hunk_header.bg = "light green"
445                "#,
446            ))
447            .extract()
448            .unwrap();
449
450        assert_eq!(config.style.hunk_header.bg, Some(Color::LightGreen));
451        assert_eq!(config.style.hunk_header.fg, Some(Color::Blue));
452    }
453}