Skip to main content

gitu/
config.rs

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