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