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