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)]
52pub(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 pub max_input_bytes: u64,
77 pub verbose_branch_status: bool,
80 #[serde(default)]
84 pub hide: Vec<String>,
85 #[serde(default)]
88 pub visit_context_lines: bool,
89 #[serde(default)]
92 pub status_cursor_on_changes: bool,
93 #[serde(default)]
94 pub diff_renderer: DiffRendererConfig,
95 #[serde(default)]
97 pub log_args: Vec<String>,
98 #[serde(default)]
100 pub show_args: Vec<String>,
101 #[serde(default)]
102 pub file_header: FileHeader,
103 #[serde(default)]
106 pub start_folded: bool,
107 #[serde(default)]
109 pub read_only: ReadOnly,
110 #[serde(default)]
115 pub commands: BTreeMap<String, String>,
116 #[serde(default)]
119 pub views: BTreeMap<String, String>,
120}
121
122#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)]
126pub enum ReadOnly {
127 #[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
145impl<'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#[derive(Default, Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
172#[serde(rename_all = "lowercase")]
173pub enum FileHeader {
174 #[default]
176 Delta,
177 Stat,
179}
180
181#[derive(Default, Debug, Deserialize)]
182pub struct DiffRendererConfig {
183 #[serde(skip)]
187 pub command: Vec<String>,
188 #[serde(default)]
191 pub features: Vec<String>,
192 #[serde(default)]
196 pub feature_keys: BTreeMap<String, char>,
197 #[serde(default)]
200 pub theme: Option<String>,
201 #[serde(default)]
204 pub builtin_themes: Vec<String>,
205 #[serde(skip)]
208 pub color_mode: Option<ColorMode>,
209 #[serde(skip)]
213 pub enabled_features: EnabledFeatures,
214 #[serde(skip)]
218 pub git_config: RendererGitConfig,
219}
220
221#[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 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#[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#[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 #[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 #[serde(default)]
370 pub active_arg: StyleConfigEntry,
371 #[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
498pub(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
543fn 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
556fn 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}