Skip to main content

gitu/
config.rs

1use std::{collections::BTreeMap, path::PathBuf};
2
3use crate::config_git::{self, FEATURE_KEYS};
4use crate::style::{Color, Modifier, Style};
5use crate::{Bindings, Res, error::Error, key_parser, menu::Menu, ops::Op};
6use crossterm::event::{KeyCode, KeyModifiers};
7use etcetera::{BaseStrategy, choose_base_strategy};
8use figment::{
9    Figment,
10    providers::{Format, Serialized, Toml},
11};
12use serde::Deserialize;
13
14const DEFAULT_CONFIG: &str = include_str!("default_config.toml");
15
16pub struct Config {
17    pub general: GeneralConfig,
18    pub style: StyleConfig,
19    pub bindings: Bindings,
20    pub picker_bindings: PickerBindings,
21}
22
23#[derive(Default, Deserialize)]
24pub(crate) struct PickerBindingsConfig {
25    #[serde(default)]
26    pub next: Vec<String>,
27    #[serde(default)]
28    pub previous: Vec<String>,
29    #[serde(default)]
30    pub done: Vec<String>,
31    #[serde(default)]
32    pub cancel: Vec<String>,
33    #[serde(default)]
34    pub toggle_list: Vec<String>,
35    #[serde(default)]
36    pub quit: Vec<String>,
37}
38
39#[derive(Default, Deserialize)]
40pub(crate) struct BindingsConfig {
41    #[serde(flatten)]
42    pub menus: BTreeMap<Menu, BTreeMap<Op, Vec<String>>>,
43    #[serde(default)]
44    pub picker: PickerBindingsConfig,
45}
46
47#[derive(Default, Deserialize)]
48/// Only used to deserialise configurations with `figment`. This should be
49/// parsed to be turned into a useful [`Config`].
50pub(crate) struct FigmentConfig {
51    pub general: GeneralConfig,
52    pub style: StyleConfig,
53    pub bindings: BindingsConfig,
54}
55
56#[derive(Default, Debug, Deserialize)]
57pub struct GeneralConfig {
58    pub always_show_help: BoolConfigEntry,
59    pub confirm_quit: BoolConfigEntry,
60    pub refresh_on_file_change: BoolConfigEntry,
61    pub confirm_discard: ConfirmDiscardOption,
62    pub collapsed_sections: Vec<String>,
63    pub stash_list_limit: usize,
64    pub recent_commits_limit: usize,
65    pub log_author_width: usize,
66    pub mouse_support: bool,
67    pub mouse_scroll_lines: usize,
68    /// The most input gitu will read, as a pager or when putting a command
69    /// again. It is read in one pass before anything is drawn, so this is what
70    /// stops a `git log -p` over a whole history from showing nothing for
71    /// minutes. What is past the limit is dropped, not deferred.
72    pub max_input_bytes: u64,
73    /// Whether the branch section uses git's wording - 'On branch {name}' over
74    /// a line placing it relative to its upstream - or the branch name alone.
75    pub verbose_branch_status: bool,
76    /// Globs whose files are dropped from a paged view, as if the patch never
77    /// had them. Generated code and fixtures recur, so hiding them is worth
78    /// saying once rather than per session.
79    #[serde(default)]
80    pub hide: Vec<String>,
81    /// Whether the cursor stops on a diff's unchanged lines, as reading a patch
82    /// line by line wants. Nothing can be staged there.
83    #[serde(default)]
84    pub visit_context_lines: bool,
85    /// Whether the status screen opens with the cursor on the first unstaged
86    /// file, or the first staged one when nothing is unstaged.
87    #[serde(default)]
88    pub status_cursor_on_changes: bool,
89    #[serde(default)]
90    pub diff_renderer: DiffRendererConfig,
91    /// Arguments given to the `git log` gitu runs for the log view.
92    #[serde(default)]
93    pub log_args: Vec<String>,
94    /// Arguments given to the `git show` gitu runs for a commit it opens.
95    #[serde(default)]
96    pub show_args: Vec<String>,
97    #[serde(default)]
98    pub file_header: FileHeader,
99    /// Whether a patch of more than one file that does not fit on one screen
100    /// starts with its files folded: `git diff`, `git show`, `git log -p`, and a
101    /// commit opened from the log.
102    #[serde(default)]
103    pub start_folded: bool,
104    /// Whether gitu leaves the repository and git config as it finds them.
105    #[serde(default)]
106    pub read_only: ReadOnly,
107}
108
109/// Whether gitu runs commands that change the repository (stage, commit,
110/// rebase, fetch, ...) and saves what is chosen in-session (a theme, feature
111/// keys, prompt history).
112#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)]
113pub enum ReadOnly {
114    /// Read-only in a repository jj also manages (one with a `.jj` directory),
115    /// where a git command can contradict jj's own state.
116    #[default]
117    Auto,
118    On,
119    Off,
120}
121
122impl ReadOnly {
123    pub fn is_on(self, repo: &git2::Repository) -> bool {
124        match self {
125            ReadOnly::Auto => repo.workdir().is_some_and(|dir| dir.join(".jj").is_dir()),
126            ReadOnly::On => true,
127            ReadOnly::Off => false,
128        }
129    }
130}
131
132/// A boolean, or `auto`. Git config gives a boolean as a string, written as
133/// git accepts one.
134impl<'de> Deserialize<'de> for ReadOnly {
135    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
136        #[derive(Deserialize)]
137        #[serde(untagged)]
138        enum Raw {
139            Bool(bool),
140            Str(String),
141        }
142        match Raw::deserialize(deserializer)? {
143            Raw::Bool(true) => Ok(ReadOnly::On),
144            Raw::Bool(false) => Ok(ReadOnly::Off),
145            Raw::Str(value) => match value.to_lowercase().as_str() {
146                "auto" => Ok(ReadOnly::Auto),
147                "true" | "yes" | "on" | "1" | "" => Ok(ReadOnly::On),
148                "false" | "no" | "off" | "0" => Ok(ReadOnly::Off),
149                _ => Err(serde::de::Error::custom(format!(
150                    "read_only: '{value}' is neither a boolean nor auto"
151                ))),
152            },
153        }
154    }
155}
156
157/// What heads each file of a patch, folded or not.
158#[derive(Default, Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
159#[serde(rename_all = "lowercase")]
160pub enum FileHeader {
161    /// The renderer's file header.
162    #[default]
163    Delta,
164    /// The file's line of a diffstat, counted by gitu from the patch.
165    Stat,
166}
167
168#[derive(Default, Debug, Deserialize)]
169pub struct DiffRendererConfig {
170    /// Renderer command as `[executable, ...args]`, set by `delta --gitu` to
171    /// itself rather than read from config. It receives a unified diff on stdin
172    /// and emits ANSI-colored output carrying OSC-1717 diff-line metadata.
173    #[serde(skip)]
174    pub command: Vec<String>,
175    /// Named renderer features offered by `renderer_features`, to turn on and
176    /// off while viewing a diff.
177    #[serde(default)]
178    pub features: Vec<String>,
179    /// Keys that toggle a feature from `renderer_features` before anything is
180    /// typed to filter it, by feature.
181    #[serde(default)]
182    pub feature_keys: BTreeMap<String, char>,
183    /// The theme overlaid on the user's own delta configuration: a feature that
184    /// sets `dark` or `light`, as delta defines a theme.
185    #[serde(default)]
186    pub theme: Option<String>,
187    /// delta's builtin features to offer as themes after those in git config,
188    /// for either mode, such as `diff-so-fancy`.
189    #[serde(default)]
190    pub builtin_themes: Vec<String>,
191    /// The mode delta renders in, set by `delta --gitu`, which settles it before
192    /// gitu starts. `None` when gitu is not run by delta.
193    #[serde(skip)]
194    pub color_mode: Option<ColorMode>,
195    /// The features delta uses with the user's own configuration, by delta's
196    /// name, set by `delta --gitu`, which resolves them before gitu starts. Empty
197    /// when gitu is not run by delta.
198    #[serde(skip)]
199    pub features_in_force: Vec<String>,
200}
201
202/// Whether delta renders for a dark or a light terminal background.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum ColorMode {
205    Dark,
206    Light,
207}
208
209#[derive(Default, Debug, Deserialize)]
210pub struct BoolConfigEntry {
211    #[serde(default)]
212    pub enabled: bool,
213}
214
215#[derive(Default, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
216#[serde(rename_all = "lowercase")]
217pub enum ConfirmDiscardOption {
218    #[default]
219    Line,
220    Hunk,
221    File,
222    Never,
223}
224
225#[derive(Default, Debug, Deserialize)]
226pub struct StyleConfig {
227    pub separator: StyleConfigEntry,
228
229    pub info_msg: StyleConfigEntry,
230    pub error_msg: StyleConfigEntry,
231    pub command: StyleConfigEntry,
232
233    #[serde(default)]
234    pub menu: MenuStyleConfig,
235
236    pub prompt: StyleConfigEntry,
237
238    pub section_header: StyleConfigEntry,
239    pub file_header: StyleConfigEntry,
240    pub hunk_header: StyleConfigEntry,
241
242    #[serde(default)]
243    pub picker: PickerStyleConfig,
244
245    pub cursor: SymbolStyleConfigEntry,
246    pub selection_bar: SymbolStyleConfigEntry,
247    pub selection_line: StyleConfigEntry,
248    pub selection_area: StyleConfigEntry,
249
250    #[serde(default)]
251    pub search_match: StyleConfigEntry,
252
253    pub hash: StyleConfigEntry,
254    /// The instruction keywords in the interactive rebase todo view.
255    #[serde(default)]
256    pub rebase_todo: RebaseTodoStyleConfig,
257    pub branch: StyleConfigEntry,
258    pub remote: StyleConfigEntry,
259    pub tag: StyleConfigEntry,
260    pub author: StyleConfigEntry,
261    pub age: StyleConfigEntry,
262
263    #[serde(default)]
264    pub blame: BlameStyleConfig,
265}
266
267#[derive(Default, Debug, Deserialize)]
268pub struct RebaseTodoStyleConfig {
269    #[serde(default)]
270    pub reword: StyleConfigEntry,
271    #[serde(default)]
272    pub edit: StyleConfigEntry,
273    #[serde(default)]
274    pub squash: StyleConfigEntry,
275    #[serde(default)]
276    pub fixup: StyleConfigEntry,
277    #[serde(default)]
278    pub drop: StyleConfigEntry,
279}
280
281#[derive(Default, Debug, Deserialize)]
282pub struct BlameStyleConfig {
283    #[serde(default)]
284    pub line_num: StyleConfigEntry,
285    #[serde(default)]
286    pub code_line: StyleConfigEntry,
287}
288
289#[derive(Default, Debug, Deserialize)]
290pub struct MenuStyleConfig {
291    #[serde(default)]
292    pub heading: StyleConfigEntry,
293    #[serde(default)]
294    pub key: StyleConfigEntry,
295    /// Active argument value display (e.g., "--interactive")
296    #[serde(default)]
297    pub active_arg: StyleConfigEntry,
298    /// Inactive argument value display
299    #[serde(default)]
300    pub inactive_arg: StyleConfigEntry,
301}
302
303#[derive(Default, Debug, Deserialize)]
304pub struct PickerStyleConfig {
305    #[serde(default)]
306    pub prompt: StyleConfigEntry,
307    #[serde(default)]
308    pub info: StyleConfigEntry,
309    #[serde(default)]
310    pub selection_line: StyleConfigEntry,
311    #[serde(default)]
312    pub matched: StyleConfigEntry,
313}
314
315#[derive(Default, Debug, Deserialize)]
316pub struct StyleConfigEntry {
317    #[serde(default)]
318    fg: Option<Color>,
319    #[serde(default)]
320    bg: Option<Color>,
321    #[serde(default)]
322    mods: Option<Modifier>,
323}
324
325#[derive(Default, Debug, Deserialize)]
326pub struct SymbolStyleConfigEntry {
327    #[serde(default)]
328    pub symbol: char,
329    #[serde(default)]
330    fg: Option<Color>,
331    #[serde(default)]
332    bg: Option<Color>,
333    #[serde(default)]
334    mods: Option<Modifier>,
335}
336
337impl From<&StyleConfigEntry> for Style {
338    fn from(val: &StyleConfigEntry) -> Self {
339        Style {
340            fg: val.fg,
341            bg: val.bg,
342            add_modifier: val.mods.unwrap_or(Modifier::empty()),
343            sub_modifier: Modifier::empty(),
344        }
345    }
346}
347
348impl From<&SymbolStyleConfigEntry> for Style {
349    fn from(val: &SymbolStyleConfigEntry) -> Self {
350        Style {
351            fg: val.fg,
352            bg: val.bg,
353            add_modifier: val.mods.unwrap_or(Modifier::empty()),
354            sub_modifier: Modifier::empty(),
355        }
356    }
357}
358
359pub struct PickerBindings {
360    pub next: Vec<Vec<(KeyModifiers, KeyCode)>>,
361    pub previous: Vec<Vec<(KeyModifiers, KeyCode)>>,
362    pub done: Vec<Vec<(KeyModifiers, KeyCode)>>,
363    pub cancel: Vec<Vec<(KeyModifiers, KeyCode)>>,
364    pub toggle_list: Vec<Vec<(KeyModifiers, KeyCode)>>,
365    pub quit: Vec<Vec<(KeyModifiers, KeyCode)>>,
366}
367
368impl TryFrom<PickerBindingsConfig> for PickerBindings {
369    type Error = crate::error::Error;
370
371    fn try_from(config: PickerBindingsConfig) -> Result<Self, Self::Error> {
372        let mut bad_bindings = Vec::new();
373
374        let next = parse_picker_keys(&config.next, "picker.next", &mut bad_bindings);
375        let previous = parse_picker_keys(&config.previous, "picker.previous", &mut bad_bindings);
376        let done = parse_picker_keys(&config.done, "picker.done", &mut bad_bindings);
377        let cancel = parse_picker_keys(&config.cancel, "picker.cancel", &mut bad_bindings);
378        let toggle_list =
379            parse_picker_keys(&config.toggle_list, "picker.toggle_list", &mut bad_bindings);
380        let quit = parse_picker_keys(&config.quit, "picker.quit", &mut bad_bindings);
381
382        if !bad_bindings.is_empty() {
383            return Err(Error::Bindings {
384                bad_key_bindings: bad_bindings,
385            });
386        }
387
388        Ok(Self {
389            next,
390            previous,
391            done,
392            cancel,
393            toggle_list,
394            quit,
395        })
396    }
397}
398
399fn parse_picker_keys(
400    raw_keys: &[String],
401    action_name: &str,
402    bad_bindings: &mut Vec<String>,
403) -> Vec<Vec<(KeyModifiers, KeyCode)>> {
404    raw_keys
405        .iter()
406        .filter_map(|keys| {
407            if let Ok(("", parsed)) = key_parser::parse_config_keys(keys) {
408                Some(parsed)
409            } else {
410                bad_bindings.push(format!("- {} = {}", action_name, keys));
411                None
412            }
413        })
414        .collect()
415}
416
417pub fn init_config(path: Option<PathBuf>) -> Res<Config> {
418    let git_config = git2::Repository::open_from_env()
419        .and_then(|repo| repo.config())
420        .or_else(|_| git2::Config::open_default())
421        .ok();
422    init_config_from(path.unwrap_or_else(config_path), git_config.as_ref())
423}
424
425/// The config in the gitu config file at `config_path`, under what `git_config`
426/// holds in its `delta-tui` section.
427pub(crate) fn init_config_from(
428    config_path: PathBuf,
429    git_config: Option<&git2::Config>,
430) -> Res<Config> {
431    if config_path.exists() {
432        log::info!("Loading config file at {config_path:?}");
433    } else {
434        log::info!("No config file at {config_path:?}");
435    }
436
437    let defaults = Figment::from(Toml::string(DEFAULT_CONFIG));
438    let file = Figment::from(Toml::file(&config_path));
439    let git = match git_config {
440        Some(git_config) => Figment::from(Serialized::defaults(config_git::read(
441            git_config, &defaults,
442        )?)),
443        None => Figment::new(),
444    };
445    let FigmentConfig {
446        mut general,
447        style,
448        bindings: bindings_config,
449    } = defaults
450        .merge(file.clone())
451        .merge(git.clone())
452        .extract()
453        .map_err(Box::new)
454        .map_err(Error::Config)?;
455    if let Some(feature_keys) = users_feature_keys(&git)?.or(users_feature_keys(&file)?) {
456        general.diff_renderer.feature_keys = feature_keys;
457    }
458    let bindings = Bindings::try_from(bindings_config.menus)?;
459    let picker_bindings = PickerBindings::try_from(bindings_config.picker)?;
460    check_feature_keys(&general.diff_renderer.feature_keys, &bindings)?;
461
462    Ok(Config {
463        general,
464        style,
465        bindings,
466        picker_bindings,
467    })
468}
469
470/// The feature keys in one layer of the user's config, which are all of them
471/// rather than additions to those under it, so that a key can be unset.
472fn users_feature_keys(figment: &Figment) -> Res<Option<BTreeMap<String, char>>> {
473    if !figment.contains(FEATURE_KEYS) {
474        return Ok(None);
475    }
476    figment
477        .extract_inner(FEATURE_KEYS)
478        .map(Some)
479        .map_err(Box::new)
480        .map_err(Error::Config)
481}
482
483/// A feature's key is a top-level key, so one that is bound already would do two
484/// things.
485fn check_feature_keys(feature_keys: &BTreeMap<String, char>, bindings: &Bindings) -> Res<()> {
486    let taken: Vec<String> = feature_keys
487        .iter()
488        .filter(|(_, key)| bindings.binds_at_top_level(**key))
489        .map(|(feature, key)| {
490            format!("- general.diff_renderer.feature_keys.{feature} = {key} (bound already)")
491        })
492        .collect();
493    if !taken.is_empty() {
494        return Err(Error::Bindings {
495            bad_key_bindings: taken,
496        });
497    }
498    Ok(())
499}
500
501pub fn config_path() -> PathBuf {
502    choose_base_strategy()
503        .expect("Unable to find the config directory!")
504        .config_dir()
505        .join("gitu/config.toml")
506}
507
508#[cfg(test)]
509pub(crate) const TEST_SEARCH_HIGHLIGHT_BG: Color = Color::LightYellow;
510
511#[cfg(test)]
512pub(crate) fn init_test_config() -> Res<Config> {
513    let FigmentConfig {
514        mut general,
515        mut style,
516        bindings: bindings_config,
517    } = Figment::new()
518        .merge(Toml::string(DEFAULT_CONFIG))
519        .extract()
520        .map_err(Box::new)
521        .map_err(Error::Config)?;
522
523    general.always_show_help.enabled = false;
524    general.refresh_on_file_change.enabled = false;
525    general.status_cursor_on_changes = false;
526    general.diff_renderer.command = crate::tests::helpers::ui::renderer();
527
528    style.search_match = StyleConfigEntry {
529        fg: None,
530        bg: Some(TEST_SEARCH_HIGHLIGHT_BG),
531        mods: None,
532    };
533
534    Ok(Config {
535        general,
536        style,
537        bindings: Bindings::try_from(bindings_config.menus).unwrap(),
538        picker_bindings: PickerBindings::try_from(bindings_config.picker).unwrap(),
539    })
540}
541
542#[cfg(test)]
543mod tests {
544    use std::fs;
545
546    use crate::style::{Color, Modifier};
547    use figment::{
548        Figment,
549        providers::{Format, Toml},
550    };
551    use temp_dir::TempDir;
552
553    use super::{Config, DEFAULT_CONFIG, FigmentConfig, init_config_from};
554
555    #[test]
556    fn config_merges() {
557        let config: FigmentConfig = Figment::new()
558            .merge(Toml::string(DEFAULT_CONFIG))
559            .merge(Toml::string(
560                r#"
561                [style]
562                hunk_header.bg = "light green"
563                "#,
564            ))
565            .extract()
566            .unwrap();
567
568        assert_eq!(config.style.hunk_header.bg, Some(Color::LightGreen));
569        assert_eq!(config.style.hunk_header.fg, Some(Color::Blue));
570    }
571
572    /// The config read from a gitu config file holding `toml` and git config
573    /// holding `gitconfig`.
574    fn configured(toml: &str, gitconfig: &str) -> Config {
575        let dir = TempDir::new().unwrap();
576        let toml_path = dir.path().join("config.toml");
577        fs::write(&toml_path, toml).unwrap();
578        let git_path = dir.path().join("gitconfig");
579        fs::write(&git_path, gitconfig).unwrap();
580        let git_config = git2::Config::open(&git_path).unwrap();
581        init_config_from(toml_path, Some(&git_config)).unwrap()
582    }
583
584    fn config_error(gitconfig: &str) -> String {
585        let dir = TempDir::new().unwrap();
586        let git_path = dir.path().join("gitconfig");
587        fs::write(&git_path, gitconfig).unwrap();
588        let git_config = git2::Config::open(&git_path).unwrap();
589        match init_config_from(dir.path().join("config.toml"), Some(&git_config)) {
590            Ok(_) => panic!("{gitconfig} was accepted"),
591            Err(e) => e.to_string(),
592        }
593    }
594
595    #[test]
596    fn git_config_sets_general_settings() {
597        let config = configured(
598            "",
599            "[delta-tui]\n\
600             \tstart-folded = true\n\
601             \tfile-header = stat\n\
602             \tstash-list-limit = 3\n",
603        );
604        assert!(config.general.start_folded);
605        assert_eq!(config.general.file_header, super::FileHeader::Stat);
606        assert_eq!(config.general.stash_list_limit, 3);
607    }
608
609    /// As git reads booleans and integers.
610    #[test]
611    fn git_config_values_are_read_as_git_reads_them() {
612        let config = configured(
613            "",
614            "[delta-tui]\n\
615             \tstart-folded\n\
616             \tvisit-context-lines = no\n\
617             \tmax-input-bytes = 4m\n",
618        );
619        assert!(config.general.start_folded);
620        assert!(!config.general.visit_context_lines);
621        assert_eq!(config.general.max_input_bytes, 4 * 1024 * 1024);
622    }
623
624    /// A list is a key given once per item, as git's own lists are.
625    #[test]
626    fn git_config_lists_are_repeated_keys() {
627        let config = configured(
628            "",
629            "[delta-tui]\n\
630             \tlog-args = --stat\n\
631             \tlog-args = --pretty=format:%h %s\n\
632             \tshow-args = --stat\n",
633        );
634        assert_eq!(config.general.log_args, ["--stat", "--pretty=format:%h %s"]);
635        assert_eq!(config.general.show_args, ["--stat"]);
636    }
637
638    #[test]
639    fn git_config_overrides_the_gitu_config_file() {
640        let config = configured(
641            "[general]\nstart_folded = true\nstash_list_limit = 3\n",
642            "[delta-tui]\n\tstart-folded = false\n",
643        );
644        assert!(!config.general.start_folded);
645        assert_eq!(config.general.stash_list_limit, 3);
646    }
647
648    #[test]
649    fn git_config_subsections_reach_nested_settings() {
650        let config = configured(
651            "",
652            "[delta-tui \"general.diff-renderer\"]\n\
653             \tfeatures = side-by-side\n\
654             \tfeatures = my-*\n",
655        );
656        assert_eq!(
657            config.general.diff_renderer.features,
658            ["side-by-side", "my-*"]
659        );
660    }
661
662    /// Builtin themes are a list, one key per item, as other lists are.
663    #[test]
664    fn git_config_lists_builtin_themes() {
665        let config = configured(
666            "",
667            "[delta-tui \"general.diff-renderer\"]\n\tbuiltin-themes = diff-so-fancy\n",
668        );
669        assert_eq!(
670            config.general.diff_renderer.builtin_themes,
671            ["diff-so-fancy"]
672        );
673    }
674
675    /// A feature key is keyed by the feature's own name, dashes and all, and
676    /// as in the gitu config file they replace the default ones.
677    #[test]
678    fn git_config_sets_feature_keys_by_feature_name() {
679        let config = configured(
680            "",
681            "[delta-tui \"general.diff-renderer.feature-keys\"]\n\
682             \tmy-feature = x\n",
683        );
684        assert_eq!(
685            config
686                .general
687                .diff_renderer
688                .feature_keys
689                .into_iter()
690                .collect::<Vec<_>>(),
691            [("my-feature".to_string(), 'x')]
692        );
693    }
694
695    #[test]
696    fn git_config_sets_bindings() {
697        let config = configured(
698            "",
699            "[delta-tui \"bindings.root\"]\n\
700             \tquit = x\n\
701             \tquit = esc\n",
702        );
703        assert!(config.bindings.binds_at_top_level('x'));
704        assert!(!config.bindings.binds_at_top_level('q'));
705    }
706
707    /// A style is written as delta writes one: attributes, then a foreground
708    /// and a background color. A color it leaves out is unset.
709    #[test]
710    fn git_config_styles_are_delta_styles() {
711        let config = configured(
712            "",
713            "[delta-tui \"style\"]\n\
714             \thunk-header = bold italic \"#ff0000\" 237\n\
715             \tfile-header = ul\n\
716             \tsection-header = normal blue\n",
717        );
718        let style = &config.style;
719        assert_eq!(style.hunk_header.fg, Some(Color::Rgb(255, 0, 0)));
720        assert_eq!(style.hunk_header.bg, Some(Color::Indexed(237)));
721        assert_eq!(
722            style.hunk_header.mods,
723            Some(Modifier::BOLD | Modifier::ITALIC)
724        );
725        assert_eq!(style.file_header.fg, None);
726        assert_eq!(style.file_header.mods, Some(Modifier::UNDERLINED));
727        assert_eq!(style.section_header.fg, None);
728        assert_eq!(style.section_header.bg, Some(Color::Blue));
729    }
730
731    #[test]
732    fn a_git_config_style_keeps_its_symbol() {
733        let config = configured("", "[delta-tui \"style\"]\n\tcursor = red\n");
734        assert_eq!(config.style.cursor.symbol, '▌');
735        assert_eq!(config.style.cursor.fg, Some(Color::Red));
736    }
737
738    #[test]
739    fn a_git_config_style_inside_a_group_of_styles() {
740        let config = configured("", "[delta-tui \"style.menu\"]\n\tkey = green\n");
741        assert_eq!(config.style.menu.key.fg, Some(Color::Green));
742    }
743
744    #[test]
745    fn a_bad_git_config_value_is_an_error_naming_the_key() {
746        assert!(
747            config_error("[delta-tui]\n\tstart-folded = maybe\n")
748                .contains("delta-tui.start-folded")
749        );
750        assert!(
751            config_error("[delta-tui \"style\"]\n\thunk-header = bold sky\n")
752                .contains("delta-tui.style.hunk-header")
753        );
754    }
755}