1use std::collections::HashMap;
8
9use serde::Deserialize;
10use thiserror::Error;
11
12use super::terminal::{
13 parse_confirm, parse_terminal_chord, TERMINAL_HEIGHT_RANGE, TERMINAL_HEIGHT_REASON,
14};
15pub use super::terminal::{TerminalConfig, TerminalProfile};
16
17#[derive(Debug, Error, Clone, PartialEq, Eq)]
19pub enum SetConfigError {
20 #[error("unknown config key '{0}'")]
22 UnknownKey(String),
23 #[error("{key}: invalid value '{value}' ({reason})")]
25 InvalidValue {
26 key: String,
28 value: String,
30 reason: String,
32 },
33}
34
35const SHELL_SPEC_REASON: &str =
40 "must be a program optionally followed by flags, e.g. \"pwsh -NoProfile -Command\"; \
41 quote a path containing spaces";
42
43const SHELL_PAUSE_REASON: &str = "must be always, on_error or never";
48
49const DELETE_MODE_REASON: &str = "must be trash or permanent";
51
52const TERMINAL_TITLE_REASON: &str = "must be off, dir or path";
54
55#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
57#[serde(deny_unknown_fields)]
58pub struct TrailConfig {
59 pub general: GeneralConfig,
61 pub navigation: NavigationConfig,
63 pub preview: PreviewConfig,
65 pub terminal: TerminalConfig,
67 pub theme: ThemeConfig,
69 pub keymap: KeymapConfig,
71 pub plugins: PluginsConfig,
73}
74
75impl TrailConfig {
76 pub fn validate(&self) -> Result<(), SetConfigError> {
83 if self.general.editor.trim().is_empty() {
84 return Err(invalid_value("general.editor", "", "must not be empty"));
85 }
86 if !self.general.shell.trim().is_empty()
89 && crate::actions::shell_exec::split_shell_spec(&self.general.shell).is_none()
90 {
91 return Err(invalid_value(
92 "general.shell",
93 &self.general.shell,
94 SHELL_SPEC_REASON,
95 ));
96 }
97 if crate::actions::shell_exec::ShellPause::parse(&self.general.shell_pause).is_none() {
98 return Err(invalid_value(
99 "general.shell_pause",
100 &self.general.shell_pause,
101 SHELL_PAUSE_REASON,
102 ));
103 }
104 if crate::actions::fs_ops::DeleteMode::parse(&self.general.delete_mode).is_none() {
105 return Err(invalid_value(
106 "general.delete_mode",
107 &self.general.delete_mode,
108 DELETE_MODE_REASON,
109 ));
110 }
111 if crate::window_title::TitleMode::parse(&self.general.terminal_title).is_none() {
112 return Err(invalid_value(
113 "general.terminal_title",
114 &self.general.terminal_title,
115 TERMINAL_TITLE_REASON,
116 ));
117 }
118 if self.general.text_sync_threshold_kb == 0 {
119 return Err(invalid_value(
120 "general.text_sync_threshold_kb",
121 "0",
122 "must be greater than zero",
123 ));
124 }
125 if crate::app::sort::SortBy::parse(&self.navigation.sort_by).is_none() {
126 return Err(invalid_value(
127 "navigation.sort_by",
128 &self.navigation.sort_by,
129 crate::app::sort::SORT_BY_REASON,
130 ));
131 }
132 if crate::ui::nav_panel::EntryDetails::parse(&self.navigation.entry_details).is_none() {
133 return Err(invalid_value(
134 "navigation.entry_details",
135 &self.navigation.entry_details,
136 crate::ui::nav_panel::ENTRY_DETAILS_REASON,
137 ));
138 }
139 if !self.preview.image_protocol.eq_ignore_ascii_case("auto")
143 && crate::preview::graphics::ImageProtocol::parse(&self.preview.image_protocol)
144 .is_none()
145 {
146 return Err(invalid_value(
147 "preview.image_protocol",
148 &self.preview.image_protocol,
149 "must be auto, kitty, iterm2, sixel, halfblocks or none",
150 ));
151 }
152 if self.preview.max_lines == 0 || self.preview.max_lines > PREVIEW_MAX_LINES_LIMIT {
155 return Err(invalid_value(
156 "preview.max_lines",
157 &self.preview.max_lines.to_string(),
158 "must be between 1 and 100000",
159 ));
160 }
161 self.terminal.validate()?;
162 validate_color_value("theme.foreground", &self.theme.foreground)?;
163 validate_color_value("theme.background", &self.theme.background)?;
164 validate_color_value("theme.border", &self.theme.border)?;
165 validate_color_value("theme.selection_fg", &self.theme.selection_fg)?;
166 validate_color_value("theme.selection_bg", &self.theme.selection_bg)?;
167 validate_color_value("theme.directory", &self.theme.directory)?;
168 validate_color_value("theme.symlink", &self.theme.symlink)?;
169 validate_color_value("theme.hidden", &self.theme.hidden)?;
170 validate_color_value("theme.status_fg", &self.theme.status_fg)?;
171 validate_color_value("theme.error", &self.theme.error)?;
172 validate_color_value("theme.search", &self.theme.search)?;
173 validate_color_value("theme.command", &self.theme.command)?;
174 validate_color_value("theme.git_clean", &self.theme.git_clean)?;
175 validate_color_value("theme.git_dirty", &self.theme.git_dirty)?;
176 validate_keymap_table("keymap.navigation", &self.keymap.navigation, NAV_ACTIONS)?;
177 validate_keymap_table("keymap.search", &self.keymap.search, SEARCH_ACTIONS)?;
178 for (action, binding) in &self.keymap.search {
179 validate_search_binding(&format!("keymap.search.{action}"), binding)?;
180 }
181 for (action, binding) in &self.keymap.terminal {
182 let key = format!("keymap.terminal.{action}");
183 if !crate::terminal::TERMINAL_ACTIONS.contains(&action.as_str()) {
184 return Err(SetConfigError::UnknownKey(key));
185 }
186 parse_terminal_chord(&key, binding)?;
187 }
188 Ok(())
189 }
190
191 pub fn set_value(&mut self, key: &str, value: &str) -> Result<(), SetConfigError> {
201 match key {
202 "general.editor" | "editor" => {
203 let editor = value.trim();
204 if editor.is_empty() {
205 return Err(invalid_value(key, value, "must not be empty"));
206 }
207 self.general.editor = editor.to_owned();
208 }
209 "general.shell" | "shell" => {
210 if crate::actions::shell_exec::split_shell_spec(value).is_none() {
214 return Err(invalid_value(key, value, SHELL_SPEC_REASON));
215 }
216 self.general.shell = value.trim().to_owned();
217 }
218 "general.shell_pause" | "shell_pause" => {
219 if crate::actions::shell_exec::ShellPause::parse(value).is_none() {
220 return Err(invalid_value(key, value, SHELL_PAUSE_REASON));
221 }
222 self.general.shell_pause = value.trim().to_ascii_lowercase();
223 }
224 "general.delete_mode" | "delete_mode" => {
225 if crate::actions::fs_ops::DeleteMode::parse(value).is_none() {
226 return Err(invalid_value(key, value, DELETE_MODE_REASON));
227 }
228 self.general.delete_mode = value.trim().to_ascii_lowercase();
229 }
230 "general.text_sync_threshold_kb" | "text_sync_threshold_kb" => {
231 self.general.text_sync_threshold_kb = parse_positive_usize(key, value)?;
232 }
233 "general.show_version" | "show_version" => {
234 self.general.show_version = parse_bool(key, value)?;
235 }
236 "general.terminal_title" | "terminal_title" => {
237 if crate::window_title::TitleMode::parse(value).is_none() {
238 return Err(invalid_value(key, value, TERMINAL_TITLE_REASON));
239 }
240 self.general.terminal_title = value.trim().to_ascii_lowercase();
241 }
242 "general.git_status_enabled" | "git_status_enabled" => {
243 self.general.git_status_enabled = parse_bool(key, value)?;
244 }
245 "general.fs_watch_debounce_ms" | "fs_watch_debounce_ms" => {
246 self.general.fs_watch_debounce_ms = parse_u64(key, value)?;
247 }
248 "navigation.sort_by" | "sort_by" => {
249 if crate::app::sort::SortBy::parse(value).is_none() {
250 return Err(invalid_value(key, value, crate::app::sort::SORT_BY_REASON));
251 }
252 self.navigation.sort_by = value.trim().to_ascii_lowercase();
253 }
254 "navigation.sort_reverse" | "sort_reverse" => {
255 self.navigation.sort_reverse = parse_bool(key, value)?;
256 }
257 "navigation.dirs_first" | "dirs_first" => {
258 self.navigation.dirs_first = parse_bool(key, value)?;
259 }
260 "navigation.entry_details" | "entry_details" => {
261 if crate::ui::nav_panel::EntryDetails::parse(value).is_none() {
262 return Err(invalid_value(
263 key,
264 value,
265 crate::ui::nav_panel::ENTRY_DETAILS_REASON,
266 ));
267 }
268 self.navigation.entry_details = value.trim().to_ascii_lowercase();
269 }
270 "navigation.scroll_margin" | "scroll_margin" => {
271 self.navigation.scroll_margin = parse_usize(key, value)?;
272 }
273 "preview.image_protocol" | "image_protocol" => {
274 if !value.trim().eq_ignore_ascii_case("auto")
275 && crate::preview::graphics::ImageProtocol::parse(value).is_none()
276 {
277 return Err(invalid_value(
278 key,
279 value,
280 "must be auto, kitty, iterm2, sixel, halfblocks or none",
281 ));
282 }
283 self.preview.image_protocol = value.trim().to_ascii_lowercase();
284 }
285 "preview.image_cell_width" | "image_cell_width" => {
286 self.preview.image_cell_width = parse_cell_size(key, value)?;
287 }
288 "preview.image_cell_height" | "image_cell_height" => {
289 self.preview.image_cell_height = parse_cell_size(key, value)?;
290 }
291 "preview.max_lines" | "max_lines" => {
292 let max_lines = parse_positive_usize(key, value)?;
293 if max_lines > PREVIEW_MAX_LINES_LIMIT {
294 return Err(invalid_value(key, value, "must be at most 100000"));
295 }
296 self.preview.max_lines = max_lines;
297 }
298 "terminal.height" => {
299 let height = value
300 .trim()
301 .parse::<u16>()
302 .map_err(|_| invalid_value(key, value, TERMINAL_HEIGHT_REASON))?;
303 if !TERMINAL_HEIGHT_RANGE.contains(&height) {
304 return Err(invalid_value(key, value, TERMINAL_HEIGHT_REASON));
305 }
306 self.terminal.height = height;
307 }
308 "terminal.confirm_quit" | "confirm_quit" => {
309 self.terminal.confirm_quit = parse_confirm(key, value)?;
310 }
311 "terminal.confirm_close" | "confirm_close" => {
312 self.terminal.confirm_close = parse_confirm(key, value)?;
313 }
314 "terminal.default_profile" | "default_profile" => {
315 let name = value.trim();
316 if !self.terminal.has_profile(name) {
317 return Err(invalid_value(key, value, "names no [[terminal.profile]]"));
318 }
319 self.terminal.default_profile = name.to_owned();
320 }
321 "theme.foreground" => self.theme.foreground = parse_color_value(key, value)?,
322 "theme.background" => self.theme.background = parse_color_value(key, value)?,
323 "theme.border" => self.theme.border = parse_color_value(key, value)?,
324 "theme.selection_fg" => self.theme.selection_fg = parse_color_value(key, value)?,
325 "theme.selection_bg" => self.theme.selection_bg = parse_color_value(key, value)?,
326 "theme.directory" => self.theme.directory = parse_color_value(key, value)?,
327 "theme.symlink" => self.theme.symlink = parse_color_value(key, value)?,
328 "theme.hidden" => self.theme.hidden = parse_color_value(key, value)?,
329 "theme.status_fg" => self.theme.status_fg = parse_color_value(key, value)?,
330 "theme.error" => self.theme.error = parse_color_value(key, value)?,
331 "theme.search" => self.theme.search = parse_color_value(key, value)?,
332 "theme.command" => self.theme.command = parse_color_value(key, value)?,
333 "theme.git_clean" => self.theme.git_clean = parse_color_value(key, value)?,
334 "theme.git_dirty" => self.theme.git_dirty = parse_color_value(key, value)?,
335 key if key.starts_with("keymap.navigation.") => {
336 let action = key.trim_start_matches("keymap.navigation.");
337 if !NAV_ACTIONS.contains(&action) {
338 return Err(SetConfigError::UnknownKey(key.to_owned()));
339 }
340 self.keymap
341 .navigation
342 .insert(action.to_owned(), parse_key_binding(key, value)?);
343 }
344 key if key.starts_with("keymap.search.") => {
345 let action = key.trim_start_matches("keymap.search.");
346 if !SEARCH_ACTIONS.contains(&action) {
347 return Err(SetConfigError::UnknownKey(key.to_owned()));
348 }
349 let binding = parse_key_binding(key, value)?;
350 validate_search_binding(key, &binding)?;
351 self.keymap.search.insert(action.to_owned(), binding);
352 }
353 key if key.starts_with("keymap.terminal.") => {
354 let action = key.trim_start_matches("keymap.terminal.");
355 if !crate::terminal::TERMINAL_ACTIONS.contains(&action) {
356 return Err(SetConfigError::UnknownKey(key.to_owned()));
357 }
358 parse_terminal_chord(key, value)?;
359 self.keymap
360 .terminal
361 .insert(action.to_owned(), value.trim().to_owned());
362 }
363 _ => return Err(SetConfigError::UnknownKey(key.to_owned())),
364 }
365
366 Ok(())
367 }
368}
369
370#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
372#[serde(deny_unknown_fields)]
373pub struct GeneralConfig {
374 pub editor: String,
376 pub shell: String,
384 pub shell_pause: String,
389 pub text_sync_threshold_kb: usize,
396 pub delete_mode: String,
400 pub show_version: bool,
406 pub terminal_title: String,
412 pub git_status_enabled: bool,
414 pub fs_watch_debounce_ms: u64,
416}
417
418#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
420#[serde(deny_unknown_fields)]
421pub struct NavigationConfig {
422 pub sort_by: String,
429 pub sort_reverse: bool,
431 pub dirs_first: bool,
433 pub entry_details: String,
440 pub scroll_margin: usize,
446}
447
448impl NavigationConfig {
449 pub fn sort_settings(&self) -> crate::app::sort::SortSettings {
455 crate::app::sort::SortSettings {
456 by: crate::app::sort::SortBy::parse(&self.sort_by).unwrap_or_default(),
457 reverse: self.sort_reverse,
458 dirs_first: self.dirs_first,
459 }
460 }
461
462 pub fn details(&self) -> crate::ui::nav_panel::EntryDetails {
467 crate::ui::nav_panel::EntryDetails::parse(&self.entry_details).unwrap_or_default()
468 }
469}
470
471#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
473#[serde(deny_unknown_fields)]
474pub struct PreviewConfig {
475 pub image_protocol: String,
478 pub image_cell_width: u16,
485 pub image_cell_height: u16,
487 pub max_lines: usize,
494}
495
496pub const PREVIEW_MAX_LINES_LIMIT: usize = 100_000;
503
504#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
506#[serde(deny_unknown_fields)]
507pub struct ThemeConfig {
508 pub foreground: String,
510 pub background: String,
512 pub border: String,
514 pub selection_fg: String,
516 pub selection_bg: String,
518 pub directory: String,
520 pub symlink: String,
522 pub hidden: String,
524 pub status_fg: String,
526 pub error: String,
528 pub search: String,
530 pub command: String,
532 pub git_clean: String,
534 pub git_dirty: String,
536}
537
538#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
540#[serde(deny_unknown_fields)]
541pub struct KeymapConfig {
542 pub navigation: HashMap<String, String>,
544 pub search: HashMap<String, String>,
546 pub terminal: HashMap<String, String>,
549}
550
551#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
553#[serde(deny_unknown_fields)]
554pub struct PluginsConfig {
555 pub enabled: Vec<String>,
557}
558
559fn parse_cell_size(key: &str, value: &str) -> Result<u16, SetConfigError> {
564 value
565 .trim()
566 .parse()
567 .map_err(|_| invalid_value(key, value, "expected a whole number, or 0 to detect"))
568}
569
570fn parse_bool(key: &str, value: &str) -> Result<bool, SetConfigError> {
571 match value.trim().to_ascii_lowercase().as_str() {
572 "true" | "yes" | "on" | "1" => Ok(true),
573 "false" | "no" | "off" | "0" => Ok(false),
574 _ => Err(invalid_value(key, value, "expected true or false")),
575 }
576}
577
578fn parse_positive_usize(key: &str, value: &str) -> Result<usize, SetConfigError> {
579 let parsed = value
580 .trim()
581 .parse::<usize>()
582 .map_err(|_| invalid_value(key, value, "expected a positive integer"))?;
583 if parsed == 0 {
584 return Err(invalid_value(key, value, "must be greater than zero"));
585 }
586 Ok(parsed)
587}
588
589fn parse_usize(key: &str, value: &str) -> Result<usize, SetConfigError> {
590 value
591 .trim()
592 .parse::<usize>()
593 .map_err(|_| invalid_value(key, value, "expected a non-negative integer"))
594}
595
596fn parse_u64(key: &str, value: &str) -> Result<u64, SetConfigError> {
597 value
598 .trim()
599 .parse::<u64>()
600 .map_err(|_| invalid_value(key, value, "expected a non-negative integer"))
601}
602
603fn parse_color_value(key: &str, value: &str) -> Result<String, SetConfigError> {
604 let trimmed = value.trim();
605 validate_color_value(key, trimmed)?;
606 Ok(trimmed.to_owned())
607}
608
609fn parse_key_binding(key: &str, value: &str) -> Result<String, SetConfigError> {
610 let trimmed = value.trim();
611 validate_key_binding(key, trimmed)?;
612 Ok(trimmed.to_owned())
613}
614
615fn validate_keymap_table(
616 table: &str,
617 values: &HashMap<String, String>,
618 allowed_actions: &[&str],
619) -> Result<(), SetConfigError> {
620 for (action, binding) in values {
621 if !allowed_actions.contains(&action.as_str()) {
622 return Err(SetConfigError::UnknownKey(format!("{table}.{action}")));
623 }
624 validate_key_binding(&format!("{table}.{action}"), binding)?;
625 }
626 Ok(())
627}
628
629fn validate_search_binding(key: &str, binding: &str) -> Result<(), SetConfigError> {
639 if binding.chars().count() == 1 {
640 return Err(invalid_value(
641 key,
642 binding,
643 "must not be a single character - those are typed into the query; use a named or modified key such as up, down, enter, esc or ctrl-n",
644 ));
645 }
646 Ok(())
647}
648
649fn validate_key_binding(key: &str, value: &str) -> Result<(), SetConfigError> {
650 if value.is_empty() {
651 return Err(invalid_value(key, value, "must not be empty"));
652 }
653 if value.chars().count() == 1 {
654 return Ok(());
655 }
656 let lower = value.to_ascii_lowercase();
657 let known = [
658 "enter",
659 "esc",
660 "backspace",
661 "tab",
662 "left",
663 "right",
664 "up",
665 "down",
666 "home",
667 "end",
668 ];
669 if known.contains(&lower.as_str()) {
670 return Ok(());
671 }
672 if let Some(rest) = lower.strip_prefix("ctrl-") {
673 if rest.chars().count() == 1 {
674 return Ok(());
675 }
676 }
677 if let Some(rest) = lower.strip_prefix("shift-") {
680 if known.contains(&rest) {
681 return Ok(());
682 }
683 }
684 if value.chars().all(|ch| !ch.is_control()) {
685 return Ok(());
686 }
687 Err(invalid_value(
688 key,
689 value,
690 "expected a printable key, named key, or ctrl-x chord",
691 ))
692}
693
694fn validate_color_value(key: &str, value: &str) -> Result<(), SetConfigError> {
695 let lower = value.trim().to_ascii_lowercase();
696 let named = [
697 "black",
698 "red",
699 "green",
700 "yellow",
701 "blue",
702 "magenta",
703 "cyan",
704 "gray",
705 "grey",
706 "dark_gray",
707 "dark_grey",
708 "darkgray",
709 "darkgrey",
710 "white",
711 "reset",
712 ];
713 if named.contains(&lower.as_str()) {
714 return Ok(());
715 }
716 if lower.len() == 7
717 && lower.starts_with('#')
718 && lower[1..].chars().all(|ch| ch.is_ascii_hexdigit())
719 {
720 return Ok(());
721 }
722 Err(invalid_value(
723 key,
724 value,
725 "expected a named color or #rrggbb",
726 ))
727}
728
729pub(super) fn invalid_value(key: &str, value: &str, reason: &str) -> SetConfigError {
730 SetConfigError::InvalidValue {
731 key: key.to_owned(),
732 value: value.to_owned(),
733 reason: reason.to_owned(),
734 }
735}
736
737const NAV_ACTIONS: &[&str] = &[
738 "move_down",
739 "move_up",
740 "jump_top",
741 "jump_bottom",
742 "enter_or_open",
743 "go_parent",
744 "history_back",
745 "history_forward",
746 "return_from_jump",
747 "refresh",
748 "toggle_hidden",
749 "sort_name",
750 "sort_size",
751 "sort_time",
752 "sort_extension",
753 "sort_reverse",
754 "sort_dirs_first",
755 "toggle_details",
756 "copy_absolute_path",
757 "copy_relative_path",
758 "copy_filename",
759 "copy_content",
760 "delete",
761 "toggle_mark",
762 "clear_marks",
763 "paste_copy",
764 "paste_move",
765 "enter_visual",
766 "repeat_command",
767 "enter_search",
768 "enter_command",
769 "quit",
770 "open_with_os",
771 "new_tab",
772 "close_tab",
773 "switch_tab_next",
774 "switch_tab_prev",
775 "preview_scroll_down",
776 "preview_scroll_up",
777 "preview_page_down",
778 "preview_page_up",
779 "preview_scroll_top",
780 "preview_scroll_bottom",
781];
782
783const SEARCH_ACTIONS: &[&str] = &["exit", "confirm", "move_down", "move_up", "delete_char"];
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788
789 #[test]
790 fn search_bindings_reject_bare_characters() {
791 let mut config = crate::config::load(None).unwrap();
792 config
793 .keymap
794 .search
795 .insert("move_down".to_owned(), "j".to_owned());
796
797 let err = config.validate().unwrap_err();
798 assert!(
799 format!("{err}").contains("single character"),
800 "expected the shadowing explanation, got: {err}"
801 );
802 }
803
804 #[test]
805 fn set_value_rejects_a_bare_character_search_binding() {
806 let mut config = crate::config::load(None).unwrap();
807 let err = config.set_value("keymap.search.move_up", "k").unwrap_err();
808 assert!(format!("{err}").contains("single character"));
809 assert_eq!(config.keymap.search.get("move_up"), Some(&"up".to_owned()));
811 }
812
813 #[test]
814 fn set_value_accepts_and_bounds_preview_max_lines() {
815 let mut config = crate::config::load(None).unwrap();
816 config.set_value("preview.max_lines", "8000").unwrap();
817 assert_eq!(config.preview.max_lines, 8000);
818 config.set_value("max_lines", "500").unwrap();
820 assert_eq!(config.preview.max_lines, 500);
821
822 for rejected in ["0", "200000"] {
823 assert!(
824 config.set_value("preview.max_lines", rejected).is_err(),
825 "{rejected} must be rejected"
826 );
827 }
828 assert_eq!(config.preview.max_lines, 500);
830 }
831
832 #[test]
833 fn preview_scroll_bindings_are_configurable() {
834 let mut config = crate::config::load(None).unwrap();
835 config
836 .set_value("keymap.navigation.preview_scroll_down", "J")
837 .unwrap();
838 config
839 .set_value("keymap.navigation.preview_scroll_bottom", "shift-end")
840 .unwrap();
841 assert_eq!(
842 config.keymap.navigation.get("preview_scroll_bottom"),
843 Some(&"shift-end".to_owned())
844 );
845 config.validate().expect("shipped defaults must validate");
846 }
847
848 #[test]
849 fn search_bindings_accept_named_and_modified_keys() {
850 let mut config = crate::config::load(None).unwrap();
851 for binding in ["down", "ctrl-n", "enter", "esc"] {
852 config
853 .set_value("keymap.search.move_down", binding)
854 .unwrap_or_else(|e| panic!("{binding} should be allowed: {e}"));
855 }
856 }
857
858 #[test]
859 fn set_value_updates_general_and_keymap_values() {
860 let mut config = crate::config::load(None).unwrap();
861 config
862 .set_value("git_status_enabled", "false")
863 .expect("bool setting should parse");
864 config
865 .set_value("keymap.navigation.move_down", "n")
866 .expect("keymap setting should parse");
867
868 assert!(!config.general.git_status_enabled);
869 assert_eq!(
870 config.keymap.navigation.get("move_down"),
871 Some(&"n".to_owned())
872 );
873 }
874
875 #[test]
876 fn the_shipped_shell_default_is_blank_and_means_platform_default() {
877 let config = crate::config::load(None).unwrap();
878 assert_eq!(config.general.shell, "");
879 config.validate().expect("a blank shell must be accepted");
881 }
882
883 #[test]
884 fn set_value_accepts_a_shell_with_flags() {
885 let mut config = crate::config::load(None).unwrap();
886 config
887 .set_value("general.shell", "pwsh -NoProfile -Command")
888 .unwrap();
889 assert_eq!(config.general.shell, "pwsh -NoProfile -Command");
890 config.set_value("shell", "bash -c").unwrap();
892 assert_eq!(config.general.shell, "bash -c");
893 config.validate().expect("a set shell must still validate");
894 }
895
896 #[test]
897 fn set_value_rejects_an_unspawnable_shell_and_keeps_the_old_one() {
898 let mut config = crate::config::load(None).unwrap();
899 config.set_value("shell", "bash -c").unwrap();
900
901 for rejected in ["\"pwsh -Command", "\"\""] {
902 let err = config.set_value("shell", rejected).unwrap_err();
903 assert!(
904 matches!(err, SetConfigError::InvalidValue { .. }),
905 "{rejected:?} should be an invalid value, got: {err}"
906 );
907 }
908 assert_eq!(config.general.shell, "bash -c");
910 }
911
912 #[test]
913 fn shell_pause_accepts_the_three_policies_and_rejects_anything_else() {
914 let mut config = crate::config::load(None).unwrap();
915 assert_eq!(
916 config.general.shell_pause, "always",
917 "the shipped default holds the screen, so command output can be read"
918 );
919
920 for accepted in ["never", "on_error", "ALWAYS"] {
921 config
922 .set_value("shell_pause", accepted)
923 .unwrap_or_else(|e| panic!("{accepted:?} should be accepted: {e}"));
924 assert_eq!(config.general.shell_pause, accepted.to_ascii_lowercase());
925 config.validate().expect("a set policy must still validate");
926 }
927
928 let err = config
929 .set_value("general.shell_pause", "sometimes")
930 .unwrap_err();
931 assert!(
932 matches!(err, SetConfigError::InvalidValue { .. }),
933 "an unknown policy should be rejected, got: {err}"
934 );
935 assert_eq!(
936 config.general.shell_pause, "always",
937 "a rejected value must not be applied"
938 );
939 }
940
941 #[test]
942 fn validate_rejects_an_unknown_shell_pause() {
943 let mut config = crate::config::load(None).unwrap();
944 config.general.shell_pause = "maybe".to_owned();
945 let err = config.validate().unwrap_err();
946 assert!(
947 format!("{err}").contains("general.shell_pause"),
948 "the error must name the key, got: {err}"
949 );
950 }
951
952 #[test]
953 fn validate_rejects_a_shell_with_an_unterminated_quote() {
954 let mut config = crate::config::load(None).unwrap();
955 config.general.shell = "\"pwsh -Command".to_owned();
956 let err = config.validate().unwrap_err();
957 assert!(
958 format!("{err}").contains("general.shell"),
959 "the error must name the key, got: {err}"
960 );
961 }
962
963 #[test]
964 fn set_value_rejects_unknown_keys_and_invalid_values() {
965 let mut config = crate::config::load(None).unwrap();
966
967 let unknown = config.set_value("keymap.navigation.fly", "f").unwrap_err();
968 assert!(matches!(unknown, SetConfigError::UnknownKey(_)));
969
970 let invalid = config.set_value("theme.directory", "nope").unwrap_err();
971 assert!(matches!(invalid, SetConfigError::InvalidValue { .. }));
972 }
973}