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. Nothing can be
80    /// staged there, so they are skipped by default; reading a patch line by
81    /// line wants them.
82    #[serde(default)]
83    pub visit_context_lines: bool,
84    #[serde(default)]
85    pub diff_renderer: DiffRendererConfig,
86    #[serde(default)]
87    pub log_renderer: LogRendererConfig,
88    #[serde(default)]
89    pub show_renderer: ShowRendererConfig,
90}
91
92#[derive(Default, Debug, Deserialize)]
93pub struct DiffRendererConfig {
94    #[serde(default)]
95    pub enabled: bool,
96    /// Renderer command as `[executable, ...args]`. It receives a unified diff
97    /// on stdin and must emit ANSI-colored output that preserves line structure.
98    #[serde(default)]
99    pub command: Vec<String>,
100    /// Named renderer features offered by `renderer_features`, to turn on and
101    /// off while viewing a diff.
102    #[serde(default)]
103    pub features: Vec<String>,
104    /// Keys that toggle a feature from `renderer_features` before anything is
105    /// typed to filter it, by feature.
106    #[serde(default)]
107    pub feature_keys: BTreeMap<String, char>,
108}
109
110#[derive(Default, Debug, Deserialize)]
111pub struct LogRendererConfig {
112    #[serde(default)]
113    pub enabled: bool,
114    /// Log command as `[executable, ...args]`, run in the repository. Its
115    /// ANSI-colored output becomes the log view. Each commit's rows are
116    /// identified by commit records, which a `{commit}` token in the format
117    /// emits (see [`crate::diff_renderer::COMMIT_RECORD_FORMAT`]), or a renderer
118    /// the output is piped through.
119    #[serde(default)]
120    pub command: Vec<String>,
121}
122
123#[derive(Default, Debug, Deserialize)]
124pub struct ShowRendererConfig {
125    #[serde(default)]
126    pub enabled: bool,
127    /// Command as `[executable, ...args]`, run in the repository with the
128    /// commit appended. Its ANSI-colored output is the commit view's header.
129    #[serde(default)]
130    pub command: Vec<String>,
131}
132
133#[derive(Default, Debug, Deserialize)]
134pub struct BoolConfigEntry {
135    #[serde(default)]
136    pub enabled: bool,
137}
138
139#[derive(Default, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
140#[serde(rename_all = "lowercase")]
141pub enum ConfirmDiscardOption {
142    #[default]
143    Line,
144    Hunk,
145    File,
146    Never,
147}
148
149#[derive(Default, Debug, Deserialize)]
150pub struct StyleConfig {
151    pub separator: StyleConfigEntry,
152
153    pub info_msg: StyleConfigEntry,
154    pub error_msg: StyleConfigEntry,
155    pub command: StyleConfigEntry,
156
157    #[serde(default)]
158    pub menu: MenuStyleConfig,
159
160    pub prompt: StyleConfigEntry,
161
162    pub section_header: StyleConfigEntry,
163    pub file_header: StyleConfigEntry,
164    pub hunk_header: StyleConfigEntry,
165
166    #[serde(default)]
167    pub diff_highlight: DiffHighlightConfig,
168
169    #[serde(default)]
170    pub syntax_highlight: SyntaxHighlightConfig,
171
172    #[serde(default)]
173    pub picker: PickerStyleConfig,
174
175    pub cursor: SymbolStyleConfigEntry,
176    pub selection_bar: SymbolStyleConfigEntry,
177    pub selection_line: StyleConfigEntry,
178    pub selection_area: StyleConfigEntry,
179
180    #[serde(default)]
181    pub search_match: StyleConfigEntry,
182
183    pub hash: StyleConfigEntry,
184    /// The instruction keywords in the interactive rebase todo view.
185    #[serde(default)]
186    pub rebase_todo: RebaseTodoStyleConfig,
187    pub branch: StyleConfigEntry,
188    pub remote: StyleConfigEntry,
189    pub tag: StyleConfigEntry,
190    pub author: StyleConfigEntry,
191    pub age: StyleConfigEntry,
192
193    #[serde(default)]
194    pub blame: BlameStyleConfig,
195}
196
197#[derive(Default, Debug, Deserialize)]
198pub struct RebaseTodoStyleConfig {
199    #[serde(default)]
200    pub reword: StyleConfigEntry,
201    #[serde(default)]
202    pub edit: StyleConfigEntry,
203    #[serde(default)]
204    pub squash: StyleConfigEntry,
205    #[serde(default)]
206    pub fixup: StyleConfigEntry,
207    #[serde(default)]
208    pub drop: StyleConfigEntry,
209}
210
211#[derive(Default, Debug, Deserialize)]
212pub struct BlameStyleConfig {
213    #[serde(default)]
214    pub line_num: StyleConfigEntry,
215    #[serde(default)]
216    pub code_line: StyleConfigEntry,
217}
218
219#[derive(Default, Debug, Deserialize)]
220pub struct MenuStyleConfig {
221    #[serde(default)]
222    pub heading: StyleConfigEntry,
223    #[serde(default)]
224    pub key: StyleConfigEntry,
225    /// Active argument value display (e.g., "--interactive")
226    #[serde(default)]
227    pub active_arg: StyleConfigEntry,
228    /// Inactive argument value display
229    #[serde(default)]
230    pub inactive_arg: StyleConfigEntry,
231}
232
233#[derive(Default, Debug, Deserialize)]
234pub struct DiffHighlightConfig {
235    #[serde(default)]
236    pub tag_old: StyleConfigEntry,
237    #[serde(default)]
238    pub tag_new: StyleConfigEntry,
239    #[serde(default)]
240    pub unchanged_old: StyleConfigEntry,
241    #[serde(default)]
242    pub unchanged_new: StyleConfigEntry,
243    #[serde(default)]
244    pub changed_old: StyleConfigEntry,
245    #[serde(default)]
246    pub changed_new: StyleConfigEntry,
247}
248
249#[derive(Default, Debug, Deserialize)]
250pub struct SyntaxHighlightConfig {
251    #[serde(default)]
252    pub enabled: bool,
253    #[serde(default)]
254    pub attribute: StyleConfigEntry,
255    #[serde(default)]
256    pub comment: StyleConfigEntry,
257    #[serde(default)]
258    pub constant_builtin: StyleConfigEntry,
259    #[serde(default)]
260    pub constant: StyleConfigEntry,
261    #[serde(default)]
262    pub constructor: StyleConfigEntry,
263    #[serde(default)]
264    pub embedded: StyleConfigEntry,
265    #[serde(default)]
266    pub function_builtin: StyleConfigEntry,
267    #[serde(default)]
268    pub function: StyleConfigEntry,
269    #[serde(default)]
270    pub keyword: StyleConfigEntry,
271    #[serde(default)]
272    pub number: StyleConfigEntry,
273    #[serde(default)]
274    pub module: StyleConfigEntry,
275    #[serde(default)]
276    pub property: StyleConfigEntry,
277    #[serde(default)]
278    pub operator: StyleConfigEntry,
279    #[serde(default)]
280    pub punctuation_bracket: StyleConfigEntry,
281    #[serde(default)]
282    pub punctuation_delimiter: StyleConfigEntry,
283    #[serde(default)]
284    pub string_special: StyleConfigEntry,
285    #[serde(default)]
286    pub string: StyleConfigEntry,
287    #[serde(default)]
288    pub tag: StyleConfigEntry,
289    #[serde(default)]
290    #[serde(rename = "type")]
291    pub type_regular: StyleConfigEntry,
292    #[serde(default)]
293    pub type_builtin: StyleConfigEntry,
294    #[serde(default)]
295    pub variable_builtin: StyleConfigEntry,
296    #[serde(default)]
297    pub variable_parameter: StyleConfigEntry,
298}
299
300#[derive(Default, Debug, Deserialize)]
301pub struct PickerStyleConfig {
302    #[serde(default)]
303    pub prompt: StyleConfigEntry,
304    #[serde(default)]
305    pub info: StyleConfigEntry,
306    #[serde(default)]
307    pub selection_line: StyleConfigEntry,
308    #[serde(default)]
309    pub matched: StyleConfigEntry,
310}
311
312#[derive(Default, Debug, Deserialize)]
313pub struct StyleConfigEntry {
314    #[serde(default)]
315    fg: Option<Color>,
316    #[serde(default)]
317    bg: Option<Color>,
318    #[serde(default)]
319    mods: Option<Modifier>,
320}
321
322#[derive(Default, Debug, Deserialize)]
323pub struct SymbolStyleConfigEntry {
324    #[serde(default)]
325    pub symbol: char,
326    #[serde(default)]
327    fg: Option<Color>,
328    #[serde(default)]
329    bg: Option<Color>,
330    #[serde(default)]
331    mods: Option<Modifier>,
332}
333
334impl From<&StyleConfigEntry> for Style {
335    fn from(val: &StyleConfigEntry) -> Self {
336        Style {
337            fg: val.fg,
338            bg: val.bg,
339            add_modifier: val.mods.unwrap_or(Modifier::empty()),
340            sub_modifier: Modifier::empty(),
341        }
342    }
343}
344
345impl From<&SymbolStyleConfigEntry> for Style {
346    fn from(val: &SymbolStyleConfigEntry) -> Self {
347        Style {
348            fg: val.fg,
349            bg: val.bg,
350            add_modifier: val.mods.unwrap_or(Modifier::empty()),
351            sub_modifier: Modifier::empty(),
352        }
353    }
354}
355
356pub struct PickerBindings {
357    pub next: Vec<Vec<(KeyModifiers, KeyCode)>>,
358    pub previous: Vec<Vec<(KeyModifiers, KeyCode)>>,
359    pub done: Vec<Vec<(KeyModifiers, KeyCode)>>,
360    pub cancel: Vec<Vec<(KeyModifiers, KeyCode)>>,
361}
362
363impl TryFrom<PickerBindingsConfig> for PickerBindings {
364    type Error = crate::error::Error;
365
366    fn try_from(config: PickerBindingsConfig) -> Result<Self, Self::Error> {
367        let mut bad_bindings = Vec::new();
368
369        let next = parse_picker_keys(&config.next, "picker.next", &mut bad_bindings);
370        let previous = parse_picker_keys(&config.previous, "picker.previous", &mut bad_bindings);
371        let done = parse_picker_keys(&config.done, "picker.done", &mut bad_bindings);
372        let cancel = parse_picker_keys(&config.cancel, "picker.cancel", &mut bad_bindings);
373
374        if !bad_bindings.is_empty() {
375            return Err(Error::Bindings {
376                bad_key_bindings: bad_bindings,
377            });
378        }
379
380        Ok(Self {
381            next,
382            previous,
383            done,
384            cancel,
385        })
386    }
387}
388
389fn parse_picker_keys(
390    raw_keys: &[String],
391    action_name: &str,
392    bad_bindings: &mut Vec<String>,
393) -> Vec<Vec<(KeyModifiers, KeyCode)>> {
394    raw_keys
395        .iter()
396        .filter_map(|keys| {
397            if let Ok(("", parsed)) = key_parser::parse_config_keys(keys) {
398                Some(parsed)
399            } else {
400                bad_bindings.push(format!("- {} = {}", action_name, keys));
401                None
402            }
403        })
404        .collect()
405}
406
407pub fn init_config(path: Option<PathBuf>) -> Res<Config> {
408    let config_path = path.unwrap_or_else(config_path);
409
410    if config_path.exists() {
411        log::info!("Loading config file at {config_path:?}");
412    } else {
413        log::info!("No config file at {config_path:?}");
414    }
415
416    let FigmentConfig {
417        general,
418        style,
419        bindings: bindings_config,
420    } = Figment::new()
421        .merge(Toml::string(DEFAULT_CONFIG))
422        .merge(Toml::file(&config_path))
423        .extract()
424        .map_err(Box::new)
425        .map_err(Error::Config)?;
426    let bindings = Bindings::try_from(bindings_config.menus)?;
427    let picker_bindings = PickerBindings::try_from(bindings_config.picker)?;
428    check_feature_keys(&general.diff_renderer.feature_keys, &bindings)?;
429
430    Ok(Config {
431        path: config_path,
432        general,
433        style,
434        bindings,
435        picker_bindings,
436    })
437}
438
439/// A feature's key is a top-level key, so one that is bound already would do two
440/// things.
441fn check_feature_keys(feature_keys: &BTreeMap<String, char>, bindings: &Bindings) -> Res<()> {
442    let taken: Vec<String> = feature_keys
443        .iter()
444        .filter(|(_, key)| bindings.binds_at_top_level(**key))
445        .map(|(feature, key)| {
446            format!("- general.diff_renderer.feature_keys.{feature} = {key} (bound already)")
447        })
448        .collect();
449    if !taken.is_empty() {
450        return Err(Error::Bindings {
451            bad_key_bindings: taken,
452        });
453    }
454    Ok(())
455}
456
457pub fn config_path() -> PathBuf {
458    choose_base_strategy()
459        .expect("Unable to find the config directory!")
460        .config_dir()
461        .join("gitu/config.toml")
462}
463
464#[cfg(test)]
465pub(crate) const TEST_SEARCH_HIGHLIGHT_BG: Color = Color::LightYellow;
466
467#[cfg(test)]
468pub(crate) fn init_test_config() -> Res<Config> {
469    let FigmentConfig {
470        mut general,
471        mut style,
472        bindings: bindings_config,
473    } = Figment::new()
474        .merge(Toml::string(DEFAULT_CONFIG))
475        .extract()
476        .map_err(Box::new)
477        .map_err(Error::Config)?;
478
479    general.always_show_help.enabled = false;
480    general.refresh_on_file_change.enabled = false;
481
482    style.search_match = StyleConfigEntry {
483        fg: None,
484        bg: Some(TEST_SEARCH_HIGHLIGHT_BG),
485        mods: None,
486    };
487
488    Ok(Config {
489        // A test that writes config says where; one that doesn't must not
490        // write the user's.
491        path: PathBuf::from("/nonexistent/gitu/config.toml"),
492        general,
493        style,
494        bindings: Bindings::try_from(bindings_config.menus).unwrap(),
495        picker_bindings: PickerBindings::try_from(bindings_config.picker).unwrap(),
496    })
497}
498
499#[cfg(test)]
500mod tests {
501    use crate::style::Color;
502    use figment::{
503        Figment,
504        providers::{Format, Toml},
505    };
506
507    use super::{DEFAULT_CONFIG, FigmentConfig};
508
509    #[test]
510    fn config_merges() {
511        let config: FigmentConfig = Figment::new()
512            .merge(Toml::string(DEFAULT_CONFIG))
513            .merge(Toml::string(
514                r#"
515                [style]
516                hunk_header.bg = "light green"
517                "#,
518            ))
519            .extract()
520            .unwrap();
521
522        assert_eq!(config.style.hunk_header.bg, Some(Color::LightGreen));
523        assert_eq!(config.style.hunk_header.fg, Some(Color::Blue));
524    }
525}