1use std::{collections::BTreeMap, path::PathBuf};
2
3use crate::config_git::{self, FEATURE_KEYS};
4use crate::style::{Color, Modifier, Style};
5use crate::{Bindings, Res, error::Error, key_parser, menu::Menu, ops::Op};
6use crossterm::event::{KeyCode, KeyModifiers};
7use etcetera::{BaseStrategy, choose_base_strategy};
8use figment::{
9 Figment,
10 providers::{Format, Serialized, Toml},
11};
12use serde::Deserialize;
13
14const DEFAULT_CONFIG: &str = include_str!("default_config.toml");
15
16pub struct Config {
17 pub general: GeneralConfig,
18 pub style: StyleConfig,
19 pub bindings: Bindings,
20 pub picker_bindings: PickerBindings,
21}
22
23#[derive(Default, Deserialize)]
24pub(crate) struct PickerBindingsConfig {
25 #[serde(default)]
26 pub next: Vec<String>,
27 #[serde(default)]
28 pub previous: Vec<String>,
29 #[serde(default)]
30 pub done: Vec<String>,
31 #[serde(default)]
32 pub cancel: Vec<String>,
33 #[serde(default)]
34 pub toggle_list: Vec<String>,
35 #[serde(default)]
36 pub quit: Vec<String>,
37}
38
39#[derive(Default, Deserialize)]
40pub(crate) struct BindingsConfig {
41 #[serde(flatten)]
42 pub menus: BTreeMap<Menu, BTreeMap<Op, Vec<String>>>,
43 #[serde(default)]
44 pub picker: PickerBindingsConfig,
45}
46
47#[derive(Default, Deserialize)]
48pub(crate) struct FigmentConfig {
51 pub general: GeneralConfig,
52 pub style: StyleConfig,
53 pub bindings: BindingsConfig,
54}
55
56#[derive(Default, Debug, Deserialize)]
57pub struct GeneralConfig {
58 pub always_show_help: BoolConfigEntry,
59 pub confirm_quit: BoolConfigEntry,
60 pub refresh_on_file_change: BoolConfigEntry,
61 pub confirm_discard: ConfirmDiscardOption,
62 pub collapsed_sections: Vec<String>,
63 pub stash_list_limit: usize,
64 pub recent_commits_limit: usize,
65 pub log_author_width: usize,
66 pub mouse_support: bool,
67 pub mouse_scroll_lines: usize,
68 pub max_input_bytes: u64,
73 pub verbose_branch_status: bool,
76 #[serde(default)]
80 pub hide: Vec<String>,
81 #[serde(default)]
84 pub visit_context_lines: bool,
85 #[serde(default)]
88 pub status_cursor_on_changes: bool,
89 #[serde(default)]
90 pub diff_renderer: DiffRendererConfig,
91 #[serde(default)]
93 pub log_args: Vec<String>,
94 #[serde(default)]
96 pub show_args: Vec<String>,
97 #[serde(default)]
98 pub file_header: FileHeader,
99 #[serde(default)]
103 pub start_folded: bool,
104}
105
106#[derive(Default, Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
108#[serde(rename_all = "lowercase")]
109pub enum FileHeader {
110 #[default]
112 Delta,
113 Stat,
115}
116
117#[derive(Default, Debug, Deserialize)]
118pub struct DiffRendererConfig {
119 #[serde(skip)]
123 pub command: Vec<String>,
124 #[serde(default)]
127 pub features: Vec<String>,
128 #[serde(default)]
131 pub feature_keys: BTreeMap<String, char>,
132 #[serde(default)]
135 pub theme: Option<String>,
136 #[serde(default)]
139 pub builtin_themes: Vec<String>,
140 #[serde(skip)]
143 pub color_mode: Option<ColorMode>,
144 #[serde(skip)]
148 pub features_in_force: Vec<String>,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum ColorMode {
154 Dark,
155 Light,
156}
157
158#[derive(Default, Debug, Deserialize)]
159pub struct BoolConfigEntry {
160 #[serde(default)]
161 pub enabled: bool,
162}
163
164#[derive(Default, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
165#[serde(rename_all = "lowercase")]
166pub enum ConfirmDiscardOption {
167 #[default]
168 Line,
169 Hunk,
170 File,
171 Never,
172}
173
174#[derive(Default, Debug, Deserialize)]
175pub struct StyleConfig {
176 pub separator: StyleConfigEntry,
177
178 pub info_msg: StyleConfigEntry,
179 pub error_msg: StyleConfigEntry,
180 pub command: StyleConfigEntry,
181
182 #[serde(default)]
183 pub menu: MenuStyleConfig,
184
185 pub prompt: StyleConfigEntry,
186
187 pub section_header: StyleConfigEntry,
188 pub file_header: StyleConfigEntry,
189 pub hunk_header: StyleConfigEntry,
190
191 #[serde(default)]
192 pub picker: PickerStyleConfig,
193
194 pub cursor: SymbolStyleConfigEntry,
195 pub selection_bar: SymbolStyleConfigEntry,
196 pub selection_line: StyleConfigEntry,
197 pub selection_area: StyleConfigEntry,
198
199 #[serde(default)]
200 pub search_match: StyleConfigEntry,
201
202 pub hash: StyleConfigEntry,
203 #[serde(default)]
205 pub rebase_todo: RebaseTodoStyleConfig,
206 pub branch: StyleConfigEntry,
207 pub remote: StyleConfigEntry,
208 pub tag: StyleConfigEntry,
209 pub author: StyleConfigEntry,
210 pub age: StyleConfigEntry,
211
212 #[serde(default)]
213 pub blame: BlameStyleConfig,
214}
215
216#[derive(Default, Debug, Deserialize)]
217pub struct RebaseTodoStyleConfig {
218 #[serde(default)]
219 pub reword: StyleConfigEntry,
220 #[serde(default)]
221 pub edit: StyleConfigEntry,
222 #[serde(default)]
223 pub squash: StyleConfigEntry,
224 #[serde(default)]
225 pub fixup: StyleConfigEntry,
226 #[serde(default)]
227 pub drop: StyleConfigEntry,
228}
229
230#[derive(Default, Debug, Deserialize)]
231pub struct BlameStyleConfig {
232 #[serde(default)]
233 pub line_num: StyleConfigEntry,
234 #[serde(default)]
235 pub code_line: StyleConfigEntry,
236}
237
238#[derive(Default, Debug, Deserialize)]
239pub struct MenuStyleConfig {
240 #[serde(default)]
241 pub heading: StyleConfigEntry,
242 #[serde(default)]
243 pub key: StyleConfigEntry,
244 #[serde(default)]
246 pub active_arg: StyleConfigEntry,
247 #[serde(default)]
249 pub inactive_arg: StyleConfigEntry,
250}
251
252#[derive(Default, Debug, Deserialize)]
253pub struct PickerStyleConfig {
254 #[serde(default)]
255 pub prompt: StyleConfigEntry,
256 #[serde(default)]
257 pub info: StyleConfigEntry,
258 #[serde(default)]
259 pub selection_line: StyleConfigEntry,
260 #[serde(default)]
261 pub matched: StyleConfigEntry,
262}
263
264#[derive(Default, Debug, Deserialize)]
265pub struct StyleConfigEntry {
266 #[serde(default)]
267 fg: Option<Color>,
268 #[serde(default)]
269 bg: Option<Color>,
270 #[serde(default)]
271 mods: Option<Modifier>,
272}
273
274#[derive(Default, Debug, Deserialize)]
275pub struct SymbolStyleConfigEntry {
276 #[serde(default)]
277 pub symbol: char,
278 #[serde(default)]
279 fg: Option<Color>,
280 #[serde(default)]
281 bg: Option<Color>,
282 #[serde(default)]
283 mods: Option<Modifier>,
284}
285
286impl From<&StyleConfigEntry> for Style {
287 fn from(val: &StyleConfigEntry) -> Self {
288 Style {
289 fg: val.fg,
290 bg: val.bg,
291 add_modifier: val.mods.unwrap_or(Modifier::empty()),
292 sub_modifier: Modifier::empty(),
293 }
294 }
295}
296
297impl From<&SymbolStyleConfigEntry> for Style {
298 fn from(val: &SymbolStyleConfigEntry) -> Self {
299 Style {
300 fg: val.fg,
301 bg: val.bg,
302 add_modifier: val.mods.unwrap_or(Modifier::empty()),
303 sub_modifier: Modifier::empty(),
304 }
305 }
306}
307
308pub struct PickerBindings {
309 pub next: Vec<Vec<(KeyModifiers, KeyCode)>>,
310 pub previous: Vec<Vec<(KeyModifiers, KeyCode)>>,
311 pub done: Vec<Vec<(KeyModifiers, KeyCode)>>,
312 pub cancel: Vec<Vec<(KeyModifiers, KeyCode)>>,
313 pub toggle_list: Vec<Vec<(KeyModifiers, KeyCode)>>,
314 pub quit: Vec<Vec<(KeyModifiers, KeyCode)>>,
315}
316
317impl TryFrom<PickerBindingsConfig> for PickerBindings {
318 type Error = crate::error::Error;
319
320 fn try_from(config: PickerBindingsConfig) -> Result<Self, Self::Error> {
321 let mut bad_bindings = Vec::new();
322
323 let next = parse_picker_keys(&config.next, "picker.next", &mut bad_bindings);
324 let previous = parse_picker_keys(&config.previous, "picker.previous", &mut bad_bindings);
325 let done = parse_picker_keys(&config.done, "picker.done", &mut bad_bindings);
326 let cancel = parse_picker_keys(&config.cancel, "picker.cancel", &mut bad_bindings);
327 let toggle_list =
328 parse_picker_keys(&config.toggle_list, "picker.toggle_list", &mut bad_bindings);
329 let quit = parse_picker_keys(&config.quit, "picker.quit", &mut bad_bindings);
330
331 if !bad_bindings.is_empty() {
332 return Err(Error::Bindings {
333 bad_key_bindings: bad_bindings,
334 });
335 }
336
337 Ok(Self {
338 next,
339 previous,
340 done,
341 cancel,
342 toggle_list,
343 quit,
344 })
345 }
346}
347
348fn parse_picker_keys(
349 raw_keys: &[String],
350 action_name: &str,
351 bad_bindings: &mut Vec<String>,
352) -> Vec<Vec<(KeyModifiers, KeyCode)>> {
353 raw_keys
354 .iter()
355 .filter_map(|keys| {
356 if let Ok(("", parsed)) = key_parser::parse_config_keys(keys) {
357 Some(parsed)
358 } else {
359 bad_bindings.push(format!("- {} = {}", action_name, keys));
360 None
361 }
362 })
363 .collect()
364}
365
366pub fn init_config(path: Option<PathBuf>) -> Res<Config> {
367 let git_config = git2::Repository::open_from_env()
368 .and_then(|repo| repo.config())
369 .or_else(|_| git2::Config::open_default())
370 .ok();
371 init_config_from(path.unwrap_or_else(config_path), git_config.as_ref())
372}
373
374pub(crate) fn init_config_from(
377 config_path: PathBuf,
378 git_config: Option<&git2::Config>,
379) -> Res<Config> {
380 if config_path.exists() {
381 log::info!("Loading config file at {config_path:?}");
382 } else {
383 log::info!("No config file at {config_path:?}");
384 }
385
386 let defaults = Figment::from(Toml::string(DEFAULT_CONFIG));
387 let file = Figment::from(Toml::file(&config_path));
388 let git = match git_config {
389 Some(git_config) => Figment::from(Serialized::defaults(config_git::read(
390 git_config, &defaults,
391 )?)),
392 None => Figment::new(),
393 };
394 let FigmentConfig {
395 mut general,
396 style,
397 bindings: bindings_config,
398 } = defaults
399 .merge(file.clone())
400 .merge(git.clone())
401 .extract()
402 .map_err(Box::new)
403 .map_err(Error::Config)?;
404 if let Some(feature_keys) = users_feature_keys(&git)?.or(users_feature_keys(&file)?) {
405 general.diff_renderer.feature_keys = feature_keys;
406 }
407 let bindings = Bindings::try_from(bindings_config.menus)?;
408 let picker_bindings = PickerBindings::try_from(bindings_config.picker)?;
409 check_feature_keys(&general.diff_renderer.feature_keys, &bindings)?;
410
411 Ok(Config {
412 general,
413 style,
414 bindings,
415 picker_bindings,
416 })
417}
418
419fn users_feature_keys(figment: &Figment) -> Res<Option<BTreeMap<String, char>>> {
422 if !figment.contains(FEATURE_KEYS) {
423 return Ok(None);
424 }
425 figment
426 .extract_inner(FEATURE_KEYS)
427 .map(Some)
428 .map_err(Box::new)
429 .map_err(Error::Config)
430}
431
432fn check_feature_keys(feature_keys: &BTreeMap<String, char>, bindings: &Bindings) -> Res<()> {
435 let taken: Vec<String> = feature_keys
436 .iter()
437 .filter(|(_, key)| bindings.binds_at_top_level(**key))
438 .map(|(feature, key)| {
439 format!("- general.diff_renderer.feature_keys.{feature} = {key} (bound already)")
440 })
441 .collect();
442 if !taken.is_empty() {
443 return Err(Error::Bindings {
444 bad_key_bindings: taken,
445 });
446 }
447 Ok(())
448}
449
450pub fn config_path() -> PathBuf {
451 choose_base_strategy()
452 .expect("Unable to find the config directory!")
453 .config_dir()
454 .join("gitu/config.toml")
455}
456
457#[cfg(test)]
458pub(crate) const TEST_SEARCH_HIGHLIGHT_BG: Color = Color::LightYellow;
459
460#[cfg(test)]
461pub(crate) fn init_test_config() -> Res<Config> {
462 let FigmentConfig {
463 mut general,
464 mut style,
465 bindings: bindings_config,
466 } = Figment::new()
467 .merge(Toml::string(DEFAULT_CONFIG))
468 .extract()
469 .map_err(Box::new)
470 .map_err(Error::Config)?;
471
472 general.always_show_help.enabled = false;
473 general.refresh_on_file_change.enabled = false;
474 general.status_cursor_on_changes = false;
475 general.diff_renderer.command = crate::tests::helpers::ui::renderer();
476
477 style.search_match = StyleConfigEntry {
478 fg: None,
479 bg: Some(TEST_SEARCH_HIGHLIGHT_BG),
480 mods: None,
481 };
482
483 Ok(Config {
484 general,
485 style,
486 bindings: Bindings::try_from(bindings_config.menus).unwrap(),
487 picker_bindings: PickerBindings::try_from(bindings_config.picker).unwrap(),
488 })
489}
490
491#[cfg(test)]
492mod tests {
493 use std::fs;
494
495 use crate::style::{Color, Modifier};
496 use figment::{
497 Figment,
498 providers::{Format, Toml},
499 };
500 use temp_dir::TempDir;
501
502 use super::{Config, DEFAULT_CONFIG, FigmentConfig, init_config_from};
503
504 #[test]
505 fn config_merges() {
506 let config: FigmentConfig = Figment::new()
507 .merge(Toml::string(DEFAULT_CONFIG))
508 .merge(Toml::string(
509 r#"
510 [style]
511 hunk_header.bg = "light green"
512 "#,
513 ))
514 .extract()
515 .unwrap();
516
517 assert_eq!(config.style.hunk_header.bg, Some(Color::LightGreen));
518 assert_eq!(config.style.hunk_header.fg, Some(Color::Blue));
519 }
520
521 fn configured(toml: &str, gitconfig: &str) -> Config {
524 let dir = TempDir::new().unwrap();
525 let toml_path = dir.path().join("config.toml");
526 fs::write(&toml_path, toml).unwrap();
527 let git_path = dir.path().join("gitconfig");
528 fs::write(&git_path, gitconfig).unwrap();
529 let git_config = git2::Config::open(&git_path).unwrap();
530 init_config_from(toml_path, Some(&git_config)).unwrap()
531 }
532
533 fn config_error(gitconfig: &str) -> String {
534 let dir = TempDir::new().unwrap();
535 let git_path = dir.path().join("gitconfig");
536 fs::write(&git_path, gitconfig).unwrap();
537 let git_config = git2::Config::open(&git_path).unwrap();
538 match init_config_from(dir.path().join("config.toml"), Some(&git_config)) {
539 Ok(_) => panic!("{gitconfig} was accepted"),
540 Err(e) => e.to_string(),
541 }
542 }
543
544 #[test]
545 fn git_config_sets_general_settings() {
546 let config = configured(
547 "",
548 "[delta-tui]\n\
549 \tstart-folded = true\n\
550 \tfile-header = stat\n\
551 \tstash-list-limit = 3\n",
552 );
553 assert!(config.general.start_folded);
554 assert_eq!(config.general.file_header, super::FileHeader::Stat);
555 assert_eq!(config.general.stash_list_limit, 3);
556 }
557
558 #[test]
560 fn git_config_values_are_read_as_git_reads_them() {
561 let config = configured(
562 "",
563 "[delta-tui]\n\
564 \tstart-folded\n\
565 \tvisit-context-lines = no\n\
566 \tmax-input-bytes = 4m\n",
567 );
568 assert!(config.general.start_folded);
569 assert!(!config.general.visit_context_lines);
570 assert_eq!(config.general.max_input_bytes, 4 * 1024 * 1024);
571 }
572
573 #[test]
575 fn git_config_lists_are_repeated_keys() {
576 let config = configured(
577 "",
578 "[delta-tui]\n\
579 \tlog-args = --stat\n\
580 \tlog-args = --pretty=format:%h %s\n\
581 \tshow-args = --stat\n",
582 );
583 assert_eq!(config.general.log_args, ["--stat", "--pretty=format:%h %s"]);
584 assert_eq!(config.general.show_args, ["--stat"]);
585 }
586
587 #[test]
588 fn git_config_overrides_the_gitu_config_file() {
589 let config = configured(
590 "[general]\nstart_folded = true\nstash_list_limit = 3\n",
591 "[delta-tui]\n\tstart-folded = false\n",
592 );
593 assert!(!config.general.start_folded);
594 assert_eq!(config.general.stash_list_limit, 3);
595 }
596
597 #[test]
598 fn git_config_subsections_reach_nested_settings() {
599 let config = configured(
600 "",
601 "[delta-tui \"general.diff-renderer\"]\n\
602 \tfeatures = side-by-side\n\
603 \tfeatures = my-*\n",
604 );
605 assert_eq!(
606 config.general.diff_renderer.features,
607 ["side-by-side", "my-*"]
608 );
609 }
610
611 #[test]
613 fn git_config_lists_builtin_themes() {
614 let config = configured(
615 "",
616 "[delta-tui \"general.diff-renderer\"]\n\tbuiltin-themes = diff-so-fancy\n",
617 );
618 assert_eq!(
619 config.general.diff_renderer.builtin_themes,
620 ["diff-so-fancy"]
621 );
622 }
623
624 #[test]
627 fn git_config_sets_feature_keys_by_feature_name() {
628 let config = configured(
629 "",
630 "[delta-tui \"general.diff-renderer.feature-keys\"]\n\
631 \tmy-feature = x\n",
632 );
633 assert_eq!(
634 config
635 .general
636 .diff_renderer
637 .feature_keys
638 .into_iter()
639 .collect::<Vec<_>>(),
640 [("my-feature".to_string(), 'x')]
641 );
642 }
643
644 #[test]
645 fn git_config_sets_bindings() {
646 let config = configured(
647 "",
648 "[delta-tui \"bindings.root\"]\n\
649 \tquit = x\n\
650 \tquit = esc\n",
651 );
652 assert!(config.bindings.binds_at_top_level('x'));
653 assert!(!config.bindings.binds_at_top_level('q'));
654 }
655
656 #[test]
659 fn git_config_styles_are_delta_styles() {
660 let config = configured(
661 "",
662 "[delta-tui \"style\"]\n\
663 \thunk-header = bold italic \"#ff0000\" 237\n\
664 \tfile-header = ul\n\
665 \tsection-header = normal blue\n",
666 );
667 let style = &config.style;
668 assert_eq!(style.hunk_header.fg, Some(Color::Rgb(255, 0, 0)));
669 assert_eq!(style.hunk_header.bg, Some(Color::Indexed(237)));
670 assert_eq!(
671 style.hunk_header.mods,
672 Some(Modifier::BOLD | Modifier::ITALIC)
673 );
674 assert_eq!(style.file_header.fg, None);
675 assert_eq!(style.file_header.mods, Some(Modifier::UNDERLINED));
676 assert_eq!(style.section_header.fg, None);
677 assert_eq!(style.section_header.bg, Some(Color::Blue));
678 }
679
680 #[test]
681 fn a_git_config_style_keeps_its_symbol() {
682 let config = configured("", "[delta-tui \"style\"]\n\tcursor = red\n");
683 assert_eq!(config.style.cursor.symbol, '▌');
684 assert_eq!(config.style.cursor.fg, Some(Color::Red));
685 }
686
687 #[test]
688 fn a_git_config_style_inside_a_group_of_styles() {
689 let config = configured("", "[delta-tui \"style.menu\"]\n\tkey = green\n");
690 assert_eq!(config.style.menu.key.fg, Some(Color::Green));
691 }
692
693 #[test]
694 fn a_bad_git_config_value_is_an_error_naming_the_key() {
695 assert!(
696 config_error("[delta-tui]\n\tstart-folded = maybe\n")
697 .contains("delta-tui.start-folded")
698 );
699 assert!(
700 config_error("[delta-tui \"style\"]\n\thunk-header = bold sky\n")
701 .contains("delta-tui.style.hunk-header")
702 );
703 }
704}