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)]
107 pub start_folded: bool,
108 #[serde(default)]
110 pub read_only: ReadOnly,
111}
112
113#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)]
117pub enum ReadOnly {
118 #[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
136impl<'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#[derive(Default, Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
163#[serde(rename_all = "lowercase")]
164pub enum FileHeader {
165 #[default]
167 Delta,
168 Stat,
170}
171
172#[derive(Default, Debug, Deserialize)]
173pub struct DiffRendererConfig {
174 #[serde(skip)]
178 pub command: Vec<String>,
179 #[serde(default)]
182 pub features: Vec<String>,
183 #[serde(default)]
186 pub feature_keys: BTreeMap<String, char>,
187 #[serde(default)]
190 pub theme: Option<String>,
191 #[serde(default)]
194 pub builtin_themes: Vec<String>,
195 #[serde(skip)]
198 pub color_mode: Option<ColorMode>,
199 #[serde(skip)]
203 pub enabled_features: EnabledFeatures,
204 #[serde(skip)]
208 pub git_config: RendererGitConfig,
209}
210
211#[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 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#[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#[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 #[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 #[serde(default)]
360 pub active_arg: StyleConfigEntry,
361 #[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
488pub(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
533fn 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
546fn 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 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 #[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 #[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 #[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 #[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 #[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}