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 #[serde(default)]
106 pub read_only: ReadOnly,
107}
108
109#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)]
113pub enum ReadOnly {
114 #[default]
117 Auto,
118 On,
119 Off,
120}
121
122impl ReadOnly {
123 pub fn is_on(self, repo: &git2::Repository) -> bool {
124 match self {
125 ReadOnly::Auto => repo.workdir().is_some_and(|dir| dir.join(".jj").is_dir()),
126 ReadOnly::On => true,
127 ReadOnly::Off => false,
128 }
129 }
130}
131
132impl<'de> Deserialize<'de> for ReadOnly {
135 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
136 #[derive(Deserialize)]
137 #[serde(untagged)]
138 enum Raw {
139 Bool(bool),
140 Str(String),
141 }
142 match Raw::deserialize(deserializer)? {
143 Raw::Bool(true) => Ok(ReadOnly::On),
144 Raw::Bool(false) => Ok(ReadOnly::Off),
145 Raw::Str(value) => match value.to_lowercase().as_str() {
146 "auto" => Ok(ReadOnly::Auto),
147 "true" | "yes" | "on" | "1" | "" => Ok(ReadOnly::On),
148 "false" | "no" | "off" | "0" => Ok(ReadOnly::Off),
149 _ => Err(serde::de::Error::custom(format!(
150 "read_only: '{value}' is neither a boolean nor auto"
151 ))),
152 },
153 }
154 }
155}
156
157#[derive(Default, Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
159#[serde(rename_all = "lowercase")]
160pub enum FileHeader {
161 #[default]
163 Delta,
164 Stat,
166}
167
168#[derive(Default, Debug, Deserialize)]
169pub struct DiffRendererConfig {
170 #[serde(skip)]
174 pub command: Vec<String>,
175 #[serde(default)]
178 pub features: Vec<String>,
179 #[serde(default)]
182 pub feature_keys: BTreeMap<String, char>,
183 #[serde(default)]
186 pub theme: Option<String>,
187 #[serde(default)]
190 pub builtin_themes: Vec<String>,
191 #[serde(skip)]
194 pub color_mode: Option<ColorMode>,
195 #[serde(skip)]
199 pub features_in_force: Vec<String>,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum ColorMode {
205 Dark,
206 Light,
207}
208
209#[derive(Default, Debug, Deserialize)]
210pub struct BoolConfigEntry {
211 #[serde(default)]
212 pub enabled: bool,
213}
214
215#[derive(Default, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
216#[serde(rename_all = "lowercase")]
217pub enum ConfirmDiscardOption {
218 #[default]
219 Line,
220 Hunk,
221 File,
222 Never,
223}
224
225#[derive(Default, Debug, Deserialize)]
226pub struct StyleConfig {
227 pub separator: StyleConfigEntry,
228
229 pub info_msg: StyleConfigEntry,
230 pub error_msg: StyleConfigEntry,
231 pub command: StyleConfigEntry,
232
233 #[serde(default)]
234 pub menu: MenuStyleConfig,
235
236 pub prompt: StyleConfigEntry,
237
238 pub section_header: StyleConfigEntry,
239 pub file_header: StyleConfigEntry,
240 pub hunk_header: StyleConfigEntry,
241
242 #[serde(default)]
243 pub picker: PickerStyleConfig,
244
245 pub cursor: SymbolStyleConfigEntry,
246 pub selection_bar: SymbolStyleConfigEntry,
247 pub selection_line: StyleConfigEntry,
248 pub selection_area: StyleConfigEntry,
249
250 #[serde(default)]
251 pub search_match: StyleConfigEntry,
252
253 pub hash: StyleConfigEntry,
254 #[serde(default)]
256 pub rebase_todo: RebaseTodoStyleConfig,
257 pub branch: StyleConfigEntry,
258 pub remote: StyleConfigEntry,
259 pub tag: StyleConfigEntry,
260 pub author: StyleConfigEntry,
261 pub age: StyleConfigEntry,
262
263 #[serde(default)]
264 pub blame: BlameStyleConfig,
265}
266
267#[derive(Default, Debug, Deserialize)]
268pub struct RebaseTodoStyleConfig {
269 #[serde(default)]
270 pub reword: StyleConfigEntry,
271 #[serde(default)]
272 pub edit: StyleConfigEntry,
273 #[serde(default)]
274 pub squash: StyleConfigEntry,
275 #[serde(default)]
276 pub fixup: StyleConfigEntry,
277 #[serde(default)]
278 pub drop: StyleConfigEntry,
279}
280
281#[derive(Default, Debug, Deserialize)]
282pub struct BlameStyleConfig {
283 #[serde(default)]
284 pub line_num: StyleConfigEntry,
285 #[serde(default)]
286 pub code_line: StyleConfigEntry,
287}
288
289#[derive(Default, Debug, Deserialize)]
290pub struct MenuStyleConfig {
291 #[serde(default)]
292 pub heading: StyleConfigEntry,
293 #[serde(default)]
294 pub key: StyleConfigEntry,
295 #[serde(default)]
297 pub active_arg: StyleConfigEntry,
298 #[serde(default)]
300 pub inactive_arg: StyleConfigEntry,
301}
302
303#[derive(Default, Debug, Deserialize)]
304pub struct PickerStyleConfig {
305 #[serde(default)]
306 pub prompt: StyleConfigEntry,
307 #[serde(default)]
308 pub info: StyleConfigEntry,
309 #[serde(default)]
310 pub selection_line: StyleConfigEntry,
311 #[serde(default)]
312 pub matched: StyleConfigEntry,
313}
314
315#[derive(Default, Debug, Deserialize)]
316pub struct StyleConfigEntry {
317 #[serde(default)]
318 fg: Option<Color>,
319 #[serde(default)]
320 bg: Option<Color>,
321 #[serde(default)]
322 mods: Option<Modifier>,
323}
324
325#[derive(Default, Debug, Deserialize)]
326pub struct SymbolStyleConfigEntry {
327 #[serde(default)]
328 pub symbol: char,
329 #[serde(default)]
330 fg: Option<Color>,
331 #[serde(default)]
332 bg: Option<Color>,
333 #[serde(default)]
334 mods: Option<Modifier>,
335}
336
337impl From<&StyleConfigEntry> for Style {
338 fn from(val: &StyleConfigEntry) -> Self {
339 Style {
340 fg: val.fg,
341 bg: val.bg,
342 add_modifier: val.mods.unwrap_or(Modifier::empty()),
343 sub_modifier: Modifier::empty(),
344 }
345 }
346}
347
348impl From<&SymbolStyleConfigEntry> for Style {
349 fn from(val: &SymbolStyleConfigEntry) -> Self {
350 Style {
351 fg: val.fg,
352 bg: val.bg,
353 add_modifier: val.mods.unwrap_or(Modifier::empty()),
354 sub_modifier: Modifier::empty(),
355 }
356 }
357}
358
359pub struct PickerBindings {
360 pub next: Vec<Vec<(KeyModifiers, KeyCode)>>,
361 pub previous: Vec<Vec<(KeyModifiers, KeyCode)>>,
362 pub done: Vec<Vec<(KeyModifiers, KeyCode)>>,
363 pub cancel: Vec<Vec<(KeyModifiers, KeyCode)>>,
364 pub toggle_list: Vec<Vec<(KeyModifiers, KeyCode)>>,
365 pub quit: Vec<Vec<(KeyModifiers, KeyCode)>>,
366}
367
368impl TryFrom<PickerBindingsConfig> for PickerBindings {
369 type Error = crate::error::Error;
370
371 fn try_from(config: PickerBindingsConfig) -> Result<Self, Self::Error> {
372 let mut bad_bindings = Vec::new();
373
374 let next = parse_picker_keys(&config.next, "picker.next", &mut bad_bindings);
375 let previous = parse_picker_keys(&config.previous, "picker.previous", &mut bad_bindings);
376 let done = parse_picker_keys(&config.done, "picker.done", &mut bad_bindings);
377 let cancel = parse_picker_keys(&config.cancel, "picker.cancel", &mut bad_bindings);
378 let toggle_list =
379 parse_picker_keys(&config.toggle_list, "picker.toggle_list", &mut bad_bindings);
380 let quit = parse_picker_keys(&config.quit, "picker.quit", &mut bad_bindings);
381
382 if !bad_bindings.is_empty() {
383 return Err(Error::Bindings {
384 bad_key_bindings: bad_bindings,
385 });
386 }
387
388 Ok(Self {
389 next,
390 previous,
391 done,
392 cancel,
393 toggle_list,
394 quit,
395 })
396 }
397}
398
399fn parse_picker_keys(
400 raw_keys: &[String],
401 action_name: &str,
402 bad_bindings: &mut Vec<String>,
403) -> Vec<Vec<(KeyModifiers, KeyCode)>> {
404 raw_keys
405 .iter()
406 .filter_map(|keys| {
407 if let Ok(("", parsed)) = key_parser::parse_config_keys(keys) {
408 Some(parsed)
409 } else {
410 bad_bindings.push(format!("- {} = {}", action_name, keys));
411 None
412 }
413 })
414 .collect()
415}
416
417pub fn init_config(path: Option<PathBuf>) -> Res<Config> {
418 let git_config = git2::Repository::open_from_env()
419 .and_then(|repo| repo.config())
420 .or_else(|_| git2::Config::open_default())
421 .ok();
422 init_config_from(path.unwrap_or_else(config_path), git_config.as_ref())
423}
424
425pub(crate) fn init_config_from(
428 config_path: PathBuf,
429 git_config: Option<&git2::Config>,
430) -> Res<Config> {
431 if config_path.exists() {
432 log::info!("Loading config file at {config_path:?}");
433 } else {
434 log::info!("No config file at {config_path:?}");
435 }
436
437 let defaults = Figment::from(Toml::string(DEFAULT_CONFIG));
438 let file = Figment::from(Toml::file(&config_path));
439 let git = match git_config {
440 Some(git_config) => Figment::from(Serialized::defaults(config_git::read(
441 git_config, &defaults,
442 )?)),
443 None => Figment::new(),
444 };
445 let FigmentConfig {
446 mut general,
447 style,
448 bindings: bindings_config,
449 } = defaults
450 .merge(file.clone())
451 .merge(git.clone())
452 .extract()
453 .map_err(Box::new)
454 .map_err(Error::Config)?;
455 if let Some(feature_keys) = users_feature_keys(&git)?.or(users_feature_keys(&file)?) {
456 general.diff_renderer.feature_keys = feature_keys;
457 }
458 let bindings = Bindings::try_from(bindings_config.menus)?;
459 let picker_bindings = PickerBindings::try_from(bindings_config.picker)?;
460 check_feature_keys(&general.diff_renderer.feature_keys, &bindings)?;
461
462 Ok(Config {
463 general,
464 style,
465 bindings,
466 picker_bindings,
467 })
468}
469
470fn users_feature_keys(figment: &Figment) -> Res<Option<BTreeMap<String, char>>> {
473 if !figment.contains(FEATURE_KEYS) {
474 return Ok(None);
475 }
476 figment
477 .extract_inner(FEATURE_KEYS)
478 .map(Some)
479 .map_err(Box::new)
480 .map_err(Error::Config)
481}
482
483fn check_feature_keys(feature_keys: &BTreeMap<String, char>, bindings: &Bindings) -> Res<()> {
486 let taken: Vec<String> = feature_keys
487 .iter()
488 .filter(|(_, key)| bindings.binds_at_top_level(**key))
489 .map(|(feature, key)| {
490 format!("- general.diff_renderer.feature_keys.{feature} = {key} (bound already)")
491 })
492 .collect();
493 if !taken.is_empty() {
494 return Err(Error::Bindings {
495 bad_key_bindings: taken,
496 });
497 }
498 Ok(())
499}
500
501pub fn config_path() -> PathBuf {
502 choose_base_strategy()
503 .expect("Unable to find the config directory!")
504 .config_dir()
505 .join("gitu/config.toml")
506}
507
508#[cfg(test)]
509pub(crate) const TEST_SEARCH_HIGHLIGHT_BG: Color = Color::LightYellow;
510
511#[cfg(test)]
512pub(crate) fn init_test_config() -> Res<Config> {
513 let FigmentConfig {
514 mut general,
515 mut style,
516 bindings: bindings_config,
517 } = Figment::new()
518 .merge(Toml::string(DEFAULT_CONFIG))
519 .extract()
520 .map_err(Box::new)
521 .map_err(Error::Config)?;
522
523 general.always_show_help.enabled = false;
524 general.refresh_on_file_change.enabled = false;
525 general.status_cursor_on_changes = false;
526 general.diff_renderer.command = crate::tests::helpers::ui::renderer();
527
528 style.search_match = StyleConfigEntry {
529 fg: None,
530 bg: Some(TEST_SEARCH_HIGHLIGHT_BG),
531 mods: None,
532 };
533
534 Ok(Config {
535 general,
536 style,
537 bindings: Bindings::try_from(bindings_config.menus).unwrap(),
538 picker_bindings: PickerBindings::try_from(bindings_config.picker).unwrap(),
539 })
540}
541
542#[cfg(test)]
543mod tests {
544 use std::fs;
545
546 use crate::style::{Color, Modifier};
547 use figment::{
548 Figment,
549 providers::{Format, Toml},
550 };
551 use temp_dir::TempDir;
552
553 use super::{Config, DEFAULT_CONFIG, FigmentConfig, init_config_from};
554
555 #[test]
556 fn config_merges() {
557 let config: FigmentConfig = Figment::new()
558 .merge(Toml::string(DEFAULT_CONFIG))
559 .merge(Toml::string(
560 r#"
561 [style]
562 hunk_header.bg = "light green"
563 "#,
564 ))
565 .extract()
566 .unwrap();
567
568 assert_eq!(config.style.hunk_header.bg, Some(Color::LightGreen));
569 assert_eq!(config.style.hunk_header.fg, Some(Color::Blue));
570 }
571
572 fn configured(toml: &str, gitconfig: &str) -> Config {
575 let dir = TempDir::new().unwrap();
576 let toml_path = dir.path().join("config.toml");
577 fs::write(&toml_path, toml).unwrap();
578 let git_path = dir.path().join("gitconfig");
579 fs::write(&git_path, gitconfig).unwrap();
580 let git_config = git2::Config::open(&git_path).unwrap();
581 init_config_from(toml_path, Some(&git_config)).unwrap()
582 }
583
584 fn config_error(gitconfig: &str) -> String {
585 let dir = TempDir::new().unwrap();
586 let git_path = dir.path().join("gitconfig");
587 fs::write(&git_path, gitconfig).unwrap();
588 let git_config = git2::Config::open(&git_path).unwrap();
589 match init_config_from(dir.path().join("config.toml"), Some(&git_config)) {
590 Ok(_) => panic!("{gitconfig} was accepted"),
591 Err(e) => e.to_string(),
592 }
593 }
594
595 #[test]
596 fn git_config_sets_general_settings() {
597 let config = configured(
598 "",
599 "[delta-tui]\n\
600 \tstart-folded = true\n\
601 \tfile-header = stat\n\
602 \tstash-list-limit = 3\n",
603 );
604 assert!(config.general.start_folded);
605 assert_eq!(config.general.file_header, super::FileHeader::Stat);
606 assert_eq!(config.general.stash_list_limit, 3);
607 }
608
609 #[test]
611 fn git_config_values_are_read_as_git_reads_them() {
612 let config = configured(
613 "",
614 "[delta-tui]\n\
615 \tstart-folded\n\
616 \tvisit-context-lines = no\n\
617 \tmax-input-bytes = 4m\n",
618 );
619 assert!(config.general.start_folded);
620 assert!(!config.general.visit_context_lines);
621 assert_eq!(config.general.max_input_bytes, 4 * 1024 * 1024);
622 }
623
624 #[test]
626 fn git_config_lists_are_repeated_keys() {
627 let config = configured(
628 "",
629 "[delta-tui]\n\
630 \tlog-args = --stat\n\
631 \tlog-args = --pretty=format:%h %s\n\
632 \tshow-args = --stat\n",
633 );
634 assert_eq!(config.general.log_args, ["--stat", "--pretty=format:%h %s"]);
635 assert_eq!(config.general.show_args, ["--stat"]);
636 }
637
638 #[test]
639 fn git_config_overrides_the_gitu_config_file() {
640 let config = configured(
641 "[general]\nstart_folded = true\nstash_list_limit = 3\n",
642 "[delta-tui]\n\tstart-folded = false\n",
643 );
644 assert!(!config.general.start_folded);
645 assert_eq!(config.general.stash_list_limit, 3);
646 }
647
648 #[test]
649 fn git_config_subsections_reach_nested_settings() {
650 let config = configured(
651 "",
652 "[delta-tui \"general.diff-renderer\"]\n\
653 \tfeatures = side-by-side\n\
654 \tfeatures = my-*\n",
655 );
656 assert_eq!(
657 config.general.diff_renderer.features,
658 ["side-by-side", "my-*"]
659 );
660 }
661
662 #[test]
664 fn git_config_lists_builtin_themes() {
665 let config = configured(
666 "",
667 "[delta-tui \"general.diff-renderer\"]\n\tbuiltin-themes = diff-so-fancy\n",
668 );
669 assert_eq!(
670 config.general.diff_renderer.builtin_themes,
671 ["diff-so-fancy"]
672 );
673 }
674
675 #[test]
678 fn git_config_sets_feature_keys_by_feature_name() {
679 let config = configured(
680 "",
681 "[delta-tui \"general.diff-renderer.feature-keys\"]\n\
682 \tmy-feature = x\n",
683 );
684 assert_eq!(
685 config
686 .general
687 .diff_renderer
688 .feature_keys
689 .into_iter()
690 .collect::<Vec<_>>(),
691 [("my-feature".to_string(), 'x')]
692 );
693 }
694
695 #[test]
696 fn git_config_sets_bindings() {
697 let config = configured(
698 "",
699 "[delta-tui \"bindings.root\"]\n\
700 \tquit = x\n\
701 \tquit = esc\n",
702 );
703 assert!(config.bindings.binds_at_top_level('x'));
704 assert!(!config.bindings.binds_at_top_level('q'));
705 }
706
707 #[test]
710 fn git_config_styles_are_delta_styles() {
711 let config = configured(
712 "",
713 "[delta-tui \"style\"]\n\
714 \thunk-header = bold italic \"#ff0000\" 237\n\
715 \tfile-header = ul\n\
716 \tsection-header = normal blue\n",
717 );
718 let style = &config.style;
719 assert_eq!(style.hunk_header.fg, Some(Color::Rgb(255, 0, 0)));
720 assert_eq!(style.hunk_header.bg, Some(Color::Indexed(237)));
721 assert_eq!(
722 style.hunk_header.mods,
723 Some(Modifier::BOLD | Modifier::ITALIC)
724 );
725 assert_eq!(style.file_header.fg, None);
726 assert_eq!(style.file_header.mods, Some(Modifier::UNDERLINED));
727 assert_eq!(style.section_header.fg, None);
728 assert_eq!(style.section_header.bg, Some(Color::Blue));
729 }
730
731 #[test]
732 fn a_git_config_style_keeps_its_symbol() {
733 let config = configured("", "[delta-tui \"style\"]\n\tcursor = red\n");
734 assert_eq!(config.style.cursor.symbol, '▌');
735 assert_eq!(config.style.cursor.fg, Some(Color::Red));
736 }
737
738 #[test]
739 fn a_git_config_style_inside_a_group_of_styles() {
740 let config = configured("", "[delta-tui \"style.menu\"]\n\tkey = green\n");
741 assert_eq!(config.style.menu.key.fg, Some(Color::Green));
742 }
743
744 #[test]
745 fn a_bad_git_config_value_is_an_error_naming_the_key() {
746 assert!(
747 config_error("[delta-tui]\n\tstart-folded = maybe\n")
748 .contains("delta-tui.start-folded")
749 );
750 assert!(
751 config_error("[delta-tui \"style\"]\n\thunk-header = bold sky\n")
752 .contains("delta-tui.style.hunk-header")
753 );
754 }
755}