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}
105
106/// What heads each file of a patch, folded or not.
107#[derive(Default, Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
108#[serde(rename_all = "lowercase")]
109pub enum FileHeader {
110    /// The renderer's file header.
111    #[default]
112    Delta,
113    /// The file's line of a diffstat, counted by gitu from the patch.
114    Stat,
115}
116
117#[derive(Default, Debug, Deserialize)]
118pub struct DiffRendererConfig {
119    /// Renderer command as `[executable, ...args]`, set by `delta --gitu` to
120    /// itself rather than read from config. It receives a unified diff on stdin
121    /// and emits ANSI-colored output carrying OSC-1717 diff-line metadata.
122    #[serde(skip)]
123    pub command: Vec<String>,
124    /// Named renderer features offered by `renderer_features`, to turn on and
125    /// off while viewing a diff.
126    #[serde(default)]
127    pub features: Vec<String>,
128    /// Keys that toggle a feature from `renderer_features` before anything is
129    /// typed to filter it, by feature.
130    #[serde(default)]
131    pub feature_keys: BTreeMap<String, char>,
132    /// The theme overlaid on the user's own delta configuration: a feature that
133    /// sets `dark` or `light`, as delta defines a theme.
134    #[serde(default)]
135    pub theme: Option<String>,
136    /// delta's builtin features to offer as themes after those in git config,
137    /// for either mode, such as `diff-so-fancy`.
138    #[serde(default)]
139    pub builtin_themes: Vec<String>,
140    /// The mode delta renders in, set by `delta --gitu`, which settles it before
141    /// gitu starts. `None` when gitu is not run by delta.
142    #[serde(skip)]
143    pub color_mode: Option<ColorMode>,
144    /// The features delta uses with the user's own configuration, by delta's
145    /// name, set by `delta --gitu`, which resolves them before gitu starts. Empty
146    /// when gitu is not run by delta.
147    #[serde(skip)]
148    pub features_in_force: Vec<String>,
149}
150
151/// Whether delta renders for a dark or a light terminal background.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum ColorMode {
154    Dark,
155    Light,
156}
157
158#[derive(Default, Debug, Deserialize)]
159pub struct BoolConfigEntry {
160    #[serde(default)]
161    pub enabled: bool,
162}
163
164#[derive(Default, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
165#[serde(rename_all = "lowercase")]
166pub enum ConfirmDiscardOption {
167    #[default]
168    Line,
169    Hunk,
170    File,
171    Never,
172}
173
174#[derive(Default, Debug, Deserialize)]
175pub struct StyleConfig {
176    pub separator: StyleConfigEntry,
177
178    pub info_msg: StyleConfigEntry,
179    pub error_msg: StyleConfigEntry,
180    pub command: StyleConfigEntry,
181
182    #[serde(default)]
183    pub menu: MenuStyleConfig,
184
185    pub prompt: StyleConfigEntry,
186
187    pub section_header: StyleConfigEntry,
188    pub file_header: StyleConfigEntry,
189    pub hunk_header: StyleConfigEntry,
190
191    #[serde(default)]
192    pub picker: PickerStyleConfig,
193
194    pub cursor: SymbolStyleConfigEntry,
195    pub selection_bar: SymbolStyleConfigEntry,
196    pub selection_line: StyleConfigEntry,
197    pub selection_area: StyleConfigEntry,
198
199    #[serde(default)]
200    pub search_match: StyleConfigEntry,
201
202    pub hash: StyleConfigEntry,
203    /// The instruction keywords in the interactive rebase todo view.
204    #[serde(default)]
205    pub rebase_todo: RebaseTodoStyleConfig,
206    pub branch: StyleConfigEntry,
207    pub remote: StyleConfigEntry,
208    pub tag: StyleConfigEntry,
209    pub author: StyleConfigEntry,
210    pub age: StyleConfigEntry,
211
212    #[serde(default)]
213    pub blame: BlameStyleConfig,
214}
215
216#[derive(Default, Debug, Deserialize)]
217pub struct RebaseTodoStyleConfig {
218    #[serde(default)]
219    pub reword: StyleConfigEntry,
220    #[serde(default)]
221    pub edit: StyleConfigEntry,
222    #[serde(default)]
223    pub squash: StyleConfigEntry,
224    #[serde(default)]
225    pub fixup: StyleConfigEntry,
226    #[serde(default)]
227    pub drop: StyleConfigEntry,
228}
229
230#[derive(Default, Debug, Deserialize)]
231pub struct BlameStyleConfig {
232    #[serde(default)]
233    pub line_num: StyleConfigEntry,
234    #[serde(default)]
235    pub code_line: StyleConfigEntry,
236}
237
238#[derive(Default, Debug, Deserialize)]
239pub struct MenuStyleConfig {
240    #[serde(default)]
241    pub heading: StyleConfigEntry,
242    #[serde(default)]
243    pub key: StyleConfigEntry,
244    /// Active argument value display (e.g., "--interactive")
245    #[serde(default)]
246    pub active_arg: StyleConfigEntry,
247    /// Inactive argument value display
248    #[serde(default)]
249    pub inactive_arg: StyleConfigEntry,
250}
251
252#[derive(Default, Debug, Deserialize)]
253pub struct PickerStyleConfig {
254    #[serde(default)]
255    pub prompt: StyleConfigEntry,
256    #[serde(default)]
257    pub info: StyleConfigEntry,
258    #[serde(default)]
259    pub selection_line: StyleConfigEntry,
260    #[serde(default)]
261    pub matched: StyleConfigEntry,
262}
263
264#[derive(Default, Debug, Deserialize)]
265pub struct StyleConfigEntry {
266    #[serde(default)]
267    fg: Option<Color>,
268    #[serde(default)]
269    bg: Option<Color>,
270    #[serde(default)]
271    mods: Option<Modifier>,
272}
273
274#[derive(Default, Debug, Deserialize)]
275pub struct SymbolStyleConfigEntry {
276    #[serde(default)]
277    pub symbol: char,
278    #[serde(default)]
279    fg: Option<Color>,
280    #[serde(default)]
281    bg: Option<Color>,
282    #[serde(default)]
283    mods: Option<Modifier>,
284}
285
286impl From<&StyleConfigEntry> for Style {
287    fn from(val: &StyleConfigEntry) -> Self {
288        Style {
289            fg: val.fg,
290            bg: val.bg,
291            add_modifier: val.mods.unwrap_or(Modifier::empty()),
292            sub_modifier: Modifier::empty(),
293        }
294    }
295}
296
297impl From<&SymbolStyleConfigEntry> for Style {
298    fn from(val: &SymbolStyleConfigEntry) -> Self {
299        Style {
300            fg: val.fg,
301            bg: val.bg,
302            add_modifier: val.mods.unwrap_or(Modifier::empty()),
303            sub_modifier: Modifier::empty(),
304        }
305    }
306}
307
308pub struct PickerBindings {
309    pub next: Vec<Vec<(KeyModifiers, KeyCode)>>,
310    pub previous: Vec<Vec<(KeyModifiers, KeyCode)>>,
311    pub done: Vec<Vec<(KeyModifiers, KeyCode)>>,
312    pub cancel: Vec<Vec<(KeyModifiers, KeyCode)>>,
313    pub toggle_list: Vec<Vec<(KeyModifiers, KeyCode)>>,
314    pub quit: Vec<Vec<(KeyModifiers, KeyCode)>>,
315}
316
317impl TryFrom<PickerBindingsConfig> for PickerBindings {
318    type Error = crate::error::Error;
319
320    fn try_from(config: PickerBindingsConfig) -> Result<Self, Self::Error> {
321        let mut bad_bindings = Vec::new();
322
323        let next = parse_picker_keys(&config.next, "picker.next", &mut bad_bindings);
324        let previous = parse_picker_keys(&config.previous, "picker.previous", &mut bad_bindings);
325        let done = parse_picker_keys(&config.done, "picker.done", &mut bad_bindings);
326        let cancel = parse_picker_keys(&config.cancel, "picker.cancel", &mut bad_bindings);
327        let toggle_list =
328            parse_picker_keys(&config.toggle_list, "picker.toggle_list", &mut bad_bindings);
329        let quit = parse_picker_keys(&config.quit, "picker.quit", &mut bad_bindings);
330
331        if !bad_bindings.is_empty() {
332            return Err(Error::Bindings {
333                bad_key_bindings: bad_bindings,
334            });
335        }
336
337        Ok(Self {
338            next,
339            previous,
340            done,
341            cancel,
342            toggle_list,
343            quit,
344        })
345    }
346}
347
348fn parse_picker_keys(
349    raw_keys: &[String],
350    action_name: &str,
351    bad_bindings: &mut Vec<String>,
352) -> Vec<Vec<(KeyModifiers, KeyCode)>> {
353    raw_keys
354        .iter()
355        .filter_map(|keys| {
356            if let Ok(("", parsed)) = key_parser::parse_config_keys(keys) {
357                Some(parsed)
358            } else {
359                bad_bindings.push(format!("- {} = {}", action_name, keys));
360                None
361            }
362        })
363        .collect()
364}
365
366pub fn init_config(path: Option<PathBuf>) -> Res<Config> {
367    let git_config = git2::Repository::open_from_env()
368        .and_then(|repo| repo.config())
369        .or_else(|_| git2::Config::open_default())
370        .ok();
371    init_config_from(path.unwrap_or_else(config_path), git_config.as_ref())
372}
373
374/// The config in the gitu config file at `config_path`, under what `git_config`
375/// holds in its `delta-tui` section.
376pub(crate) fn init_config_from(
377    config_path: PathBuf,
378    git_config: Option<&git2::Config>,
379) -> Res<Config> {
380    if config_path.exists() {
381        log::info!("Loading config file at {config_path:?}");
382    } else {
383        log::info!("No config file at {config_path:?}");
384    }
385
386    let defaults = Figment::from(Toml::string(DEFAULT_CONFIG));
387    let file = Figment::from(Toml::file(&config_path));
388    let git = match git_config {
389        Some(git_config) => Figment::from(Serialized::defaults(config_git::read(
390            git_config, &defaults,
391        )?)),
392        None => Figment::new(),
393    };
394    let FigmentConfig {
395        mut general,
396        style,
397        bindings: bindings_config,
398    } = defaults
399        .merge(file.clone())
400        .merge(git.clone())
401        .extract()
402        .map_err(Box::new)
403        .map_err(Error::Config)?;
404    if let Some(feature_keys) = users_feature_keys(&git)?.or(users_feature_keys(&file)?) {
405        general.diff_renderer.feature_keys = feature_keys;
406    }
407    let bindings = Bindings::try_from(bindings_config.menus)?;
408    let picker_bindings = PickerBindings::try_from(bindings_config.picker)?;
409    check_feature_keys(&general.diff_renderer.feature_keys, &bindings)?;
410
411    Ok(Config {
412        general,
413        style,
414        bindings,
415        picker_bindings,
416    })
417}
418
419/// The feature keys in one layer of the user's config, which are all of them
420/// rather than additions to those under it, so that a key can be unset.
421fn users_feature_keys(figment: &Figment) -> Res<Option<BTreeMap<String, char>>> {
422    if !figment.contains(FEATURE_KEYS) {
423        return Ok(None);
424    }
425    figment
426        .extract_inner(FEATURE_KEYS)
427        .map(Some)
428        .map_err(Box::new)
429        .map_err(Error::Config)
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    general.status_cursor_on_changes = false;
475    general.diff_renderer.command = crate::tests::helpers::ui::renderer();
476
477    style.search_match = StyleConfigEntry {
478        fg: None,
479        bg: Some(TEST_SEARCH_HIGHLIGHT_BG),
480        mods: None,
481    };
482
483    Ok(Config {
484        general,
485        style,
486        bindings: Bindings::try_from(bindings_config.menus).unwrap(),
487        picker_bindings: PickerBindings::try_from(bindings_config.picker).unwrap(),
488    })
489}
490
491#[cfg(test)]
492mod tests {
493    use std::fs;
494
495    use crate::style::{Color, Modifier};
496    use figment::{
497        Figment,
498        providers::{Format, Toml},
499    };
500    use temp_dir::TempDir;
501
502    use super::{Config, DEFAULT_CONFIG, FigmentConfig, init_config_from};
503
504    #[test]
505    fn config_merges() {
506        let config: FigmentConfig = Figment::new()
507            .merge(Toml::string(DEFAULT_CONFIG))
508            .merge(Toml::string(
509                r#"
510                [style]
511                hunk_header.bg = "light green"
512                "#,
513            ))
514            .extract()
515            .unwrap();
516
517        assert_eq!(config.style.hunk_header.bg, Some(Color::LightGreen));
518        assert_eq!(config.style.hunk_header.fg, Some(Color::Blue));
519    }
520
521    /// The config read from a gitu config file holding `toml` and git config
522    /// holding `gitconfig`.
523    fn configured(toml: &str, gitconfig: &str) -> Config {
524        let dir = TempDir::new().unwrap();
525        let toml_path = dir.path().join("config.toml");
526        fs::write(&toml_path, toml).unwrap();
527        let git_path = dir.path().join("gitconfig");
528        fs::write(&git_path, gitconfig).unwrap();
529        let git_config = git2::Config::open(&git_path).unwrap();
530        init_config_from(toml_path, Some(&git_config)).unwrap()
531    }
532
533    fn config_error(gitconfig: &str) -> String {
534        let dir = TempDir::new().unwrap();
535        let git_path = dir.path().join("gitconfig");
536        fs::write(&git_path, gitconfig).unwrap();
537        let git_config = git2::Config::open(&git_path).unwrap();
538        match init_config_from(dir.path().join("config.toml"), Some(&git_config)) {
539            Ok(_) => panic!("{gitconfig} was accepted"),
540            Err(e) => e.to_string(),
541        }
542    }
543
544    #[test]
545    fn git_config_sets_general_settings() {
546        let config = configured(
547            "",
548            "[delta-tui]\n\
549             \tstart-folded = true\n\
550             \tfile-header = stat\n\
551             \tstash-list-limit = 3\n",
552        );
553        assert!(config.general.start_folded);
554        assert_eq!(config.general.file_header, super::FileHeader::Stat);
555        assert_eq!(config.general.stash_list_limit, 3);
556    }
557
558    /// As git reads booleans and integers.
559    #[test]
560    fn git_config_values_are_read_as_git_reads_them() {
561        let config = configured(
562            "",
563            "[delta-tui]\n\
564             \tstart-folded\n\
565             \tvisit-context-lines = no\n\
566             \tmax-input-bytes = 4m\n",
567        );
568        assert!(config.general.start_folded);
569        assert!(!config.general.visit_context_lines);
570        assert_eq!(config.general.max_input_bytes, 4 * 1024 * 1024);
571    }
572
573    /// A list is a key given once per item, as git's own lists are.
574    #[test]
575    fn git_config_lists_are_repeated_keys() {
576        let config = configured(
577            "",
578            "[delta-tui]\n\
579             \tlog-args = --stat\n\
580             \tlog-args = --pretty=format:%h %s\n\
581             \tshow-args = --stat\n",
582        );
583        assert_eq!(config.general.log_args, ["--stat", "--pretty=format:%h %s"]);
584        assert_eq!(config.general.show_args, ["--stat"]);
585    }
586
587    #[test]
588    fn git_config_overrides_the_gitu_config_file() {
589        let config = configured(
590            "[general]\nstart_folded = true\nstash_list_limit = 3\n",
591            "[delta-tui]\n\tstart-folded = false\n",
592        );
593        assert!(!config.general.start_folded);
594        assert_eq!(config.general.stash_list_limit, 3);
595    }
596
597    #[test]
598    fn git_config_subsections_reach_nested_settings() {
599        let config = configured(
600            "",
601            "[delta-tui \"general.diff-renderer\"]\n\
602             \tfeatures = side-by-side\n\
603             \tfeatures = my-*\n",
604        );
605        assert_eq!(
606            config.general.diff_renderer.features,
607            ["side-by-side", "my-*"]
608        );
609    }
610
611    /// Builtin themes are a list, one key per item, as other lists are.
612    #[test]
613    fn git_config_lists_builtin_themes() {
614        let config = configured(
615            "",
616            "[delta-tui \"general.diff-renderer\"]\n\tbuiltin-themes = diff-so-fancy\n",
617        );
618        assert_eq!(
619            config.general.diff_renderer.builtin_themes,
620            ["diff-so-fancy"]
621        );
622    }
623
624    /// A feature key is keyed by the feature's own name, dashes and all, and
625    /// as in the gitu config file they replace the default ones.
626    #[test]
627    fn git_config_sets_feature_keys_by_feature_name() {
628        let config = configured(
629            "",
630            "[delta-tui \"general.diff-renderer.feature-keys\"]\n\
631             \tmy-feature = x\n",
632        );
633        assert_eq!(
634            config
635                .general
636                .diff_renderer
637                .feature_keys
638                .into_iter()
639                .collect::<Vec<_>>(),
640            [("my-feature".to_string(), 'x')]
641        );
642    }
643
644    #[test]
645    fn git_config_sets_bindings() {
646        let config = configured(
647            "",
648            "[delta-tui \"bindings.root\"]\n\
649             \tquit = x\n\
650             \tquit = esc\n",
651        );
652        assert!(config.bindings.binds_at_top_level('x'));
653        assert!(!config.bindings.binds_at_top_level('q'));
654    }
655
656    /// A style is written as delta writes one: attributes, then a foreground
657    /// and a background color. A color it leaves out is unset.
658    #[test]
659    fn git_config_styles_are_delta_styles() {
660        let config = configured(
661            "",
662            "[delta-tui \"style\"]\n\
663             \thunk-header = bold italic \"#ff0000\" 237\n\
664             \tfile-header = ul\n\
665             \tsection-header = normal blue\n",
666        );
667        let style = &config.style;
668        assert_eq!(style.hunk_header.fg, Some(Color::Rgb(255, 0, 0)));
669        assert_eq!(style.hunk_header.bg, Some(Color::Indexed(237)));
670        assert_eq!(
671            style.hunk_header.mods,
672            Some(Modifier::BOLD | Modifier::ITALIC)
673        );
674        assert_eq!(style.file_header.fg, None);
675        assert_eq!(style.file_header.mods, Some(Modifier::UNDERLINED));
676        assert_eq!(style.section_header.fg, None);
677        assert_eq!(style.section_header.bg, Some(Color::Blue));
678    }
679
680    #[test]
681    fn a_git_config_style_keeps_its_symbol() {
682        let config = configured("", "[delta-tui \"style\"]\n\tcursor = red\n");
683        assert_eq!(config.style.cursor.symbol, '▌');
684        assert_eq!(config.style.cursor.fg, Some(Color::Red));
685    }
686
687    #[test]
688    fn a_git_config_style_inside_a_group_of_styles() {
689        let config = configured("", "[delta-tui \"style.menu\"]\n\tkey = green\n");
690        assert_eq!(config.style.menu.key.fg, Some(Color::Green));
691    }
692
693    #[test]
694    fn a_bad_git_config_value_is_an_error_naming_the_key() {
695        assert!(
696            config_error("[delta-tui]\n\tstart-folded = maybe\n")
697                .contains("delta-tui.start-folded")
698        );
699        assert!(
700            config_error("[delta-tui \"style\"]\n\thunk-header = bold sky\n")
701                .contains("delta-tui.style.hunk-header")
702        );
703    }
704}