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