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