Skip to main content

scooter_core/
config.rs

1use anyhow::anyhow;
2use etcetera::base_strategy::{BaseStrategy, choose_base_strategy};
3use serde::{Deserialize, Deserializer, de};
4use std::{
5    fs,
6    path::{Path, PathBuf},
7    sync::OnceLock,
8};
9use two_face::re_exports::syntect::highlighting::{Theme, ThemeSet};
10
11mod keys;
12pub use keys::*;
13
14pub const APP_NAME: &str = "scooter";
15
16static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
17fn get_theme_set() -> &'static ThemeSet {
18    THEME_SET.get_or_init(|| {
19        let mut themes = ThemeSet::load_defaults();
20        let theme_folder = themes_folder();
21        if theme_folder.exists() {
22            themes.add_from_folder(theme_folder).unwrap();
23        }
24        themes
25    })
26}
27
28static CONFIG_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
29
30pub fn set_config_dir_override(dir: &Path) {
31    CONFIG_DIR_OVERRIDE
32        .set(dir.to_path_buf())
33        .expect("Config dir override should only be set once");
34}
35
36fn config_dir() -> PathBuf {
37    if let Some(dir) = CONFIG_DIR_OVERRIDE.get() {
38        return dir.clone();
39    }
40    let strategy = choose_base_strategy().expect("Unable to find config directory!");
41    strategy.config_dir().join(APP_NAME)
42}
43
44fn config_file() -> PathBuf {
45    config_dir().join("config.toml")
46}
47
48fn themes_folder() -> PathBuf {
49    config_dir().join("themes/")
50}
51
52#[derive(Debug, Default, Deserialize, Clone, PartialEq)]
53#[serde(deny_unknown_fields)]
54pub struct Config {
55    #[serde(default)]
56    pub editor_open: EditorOpenConfig,
57    #[serde(default)]
58    pub preview: PreviewConfig,
59    #[serde(default)]
60    pub style: StyleConfig,
61    #[serde(default)]
62    pub search: SearchConfig,
63    #[serde(default)]
64    pub keys: KeysConfig,
65}
66
67impl Config {
68    /// Returns `None` if the user wants syntax highlighting disabled, otherwise `Some(theme)` where `theme`
69    /// is the user's selected theme or otherwise the default
70    pub fn get_theme(&self) -> Option<&Theme> {
71        if self.preview.syntax_highlighting {
72            Some(&self.preview.syntax_highlighting_theme)
73        } else {
74            None
75        }
76    }
77}
78
79pub fn load_config() -> anyhow::Result<Config> {
80    let config_file = &config_file();
81    if fs::exists(config_file)? {
82        let contents = fs::read_to_string(config_file)?;
83        let config = toml::from_str(&contents)?;
84        Ok(config)
85    } else {
86        Ok(Config::default())
87    }
88}
89
90#[derive(Debug, Deserialize, Clone, PartialEq)]
91#[serde(deny_unknown_fields, default)]
92#[derive(Default)]
93pub struct EditorOpenConfig {
94    /// The command used when pressing `e` on the search results page. Two variables are available: `%file`, which will be replaced
95    /// with the file path of the search result, and `%line`, which will be replaced with the line number of the result. For example:
96    /// ```toml
97    /// [editor_open]
98    /// command = "vi %file +%line"
99    /// ```
100    /// If not set explicitly, scooter will attempt to use the editor set by the `$EDITOR` environment variable.
101    ///
102    /// This can be overridden using the `--editor-command` flag, for example: `scooter --editor-command "vi %file +%line"`.
103    pub command: Option<String>,
104    /// Whether to exit scooter after running the command defined by `editor_open.command`. Defaults to `false`.
105    pub exit: bool,
106}
107
108#[derive(Debug, Deserialize, Clone, PartialEq)]
109#[serde(deny_unknown_fields, default)]
110pub struct PreviewConfig {
111    /// Whether to apply syntax highlighting to the preview. Defaults to `true`.
112    pub syntax_highlighting: bool,
113    /// The theme to use when syntax highlighting is enabled.
114    ///
115    /// The default is `"base16-eighties.dark"`. Other built-in options are
116    /// `"base16-mocha.dark"`, `"base16-ocean.dark"`, `"base16-ocean.light"`, `"InspiredGitHub"`, `"Solarized (dark)"` and `"Solarized (light)"`.
117    ///
118    /// You can use other themes by adding `.tmTheme` files to `<scooter-config-dir>/themes` and then specifying their name here.
119    /// By default, `<scooter-config-dir>` is `~/.config/scooter/` on Linux or macOS, or `%AppData%\scooter\` on Windows, and can be overridden with the `--config-dir` flag.
120    ///
121    /// For instance, to use Catppuccin Macchiato (from [here](https://github.com/catppuccin/bat)), on Linux or macOS run:
122    /// ```sh
123    /// wget -P ~/.config/scooter/themes https://github.com/catppuccin/bat/raw/main/themes/Catppuccin%20Macchiato.tmTheme
124    /// ```
125    /// and then set `syntax_highlighting_theme = "Catppuccin Macchiato"`.
126    #[serde(deserialize_with = "deserialize_syntax_highlighting_theme")]
127    pub syntax_highlighting_theme: Theme,
128    /// Wrap text onto the next line if it is wider than the preview window. Defaults to `false`. (Can be toggled in the UI using `ctrl+l`.)
129    pub wrap_text: bool,
130}
131
132impl Default for PreviewConfig {
133    fn default() -> Self {
134        Self {
135            syntax_highlighting: true,
136            syntax_highlighting_theme: load_theme("base16-eighties.dark").unwrap(),
137            wrap_text: false,
138        }
139    }
140}
141
142fn load_theme(theme_name: &str) -> anyhow::Result<Theme> {
143    let themes = get_theme_set();
144    match themes.themes.get(theme_name) {
145        Some(theme) => Ok(theme.clone()),
146        None => Err(anyhow!(
147            "Could not find theme {theme_name}, found {:?}",
148            themes.themes.keys()
149        )),
150    }
151}
152
153fn deserialize_syntax_highlighting_theme<'de, D>(deserializer: D) -> Result<Theme, D::Error>
154where
155    D: Deserializer<'de>,
156{
157    let theme_name = String::deserialize(deserializer)?;
158    load_theme(&theme_name).map_err(de::Error::custom)
159}
160
161#[derive(Debug, Deserialize, Clone, PartialEq)]
162#[serde(deny_unknown_fields, default)]
163pub struct StyleConfig {
164    /// Force enable or disable true color. `true` forces true color (supported by most modern terminals but not e.g. Apple Terminal), while `false` forces 256 colors (supported by almost all terminals including Apple Terminal).
165    /// If omitted, scooter will attempt to determine whether the terminal being used supports true color.
166    pub true_color: bool,
167}
168
169impl Default for StyleConfig {
170    fn default() -> Self {
171        Self {
172            true_color: detect_true_colour(),
173        }
174    }
175}
176
177#[cfg(windows)]
178fn detect_true_colour() -> bool {
179    true
180}
181
182// Copied from Helix
183#[cfg(not(windows))]
184fn detect_true_colour() -> bool {
185    if matches!(
186        std::env::var("COLORTERM").map(|v| matches!(v.as_str(), "truecolor" | "24bit")),
187        Ok(true)
188    ) {
189        return true;
190    }
191
192    match termini::TermInfo::from_env() {
193        Ok(t) => {
194            t.extended_cap("RGB").is_some()
195                || t.extended_cap("Tc").is_some()
196                || (t.extended_cap("setrgbf").is_some() && t.extended_cap("setrgbb").is_some())
197        }
198        Err(_) => false,
199    }
200}
201
202#[derive(Debug, Deserialize, Clone, PartialEq)]
203#[serde(deny_unknown_fields, default)]
204pub struct SearchConfig {
205    /// Whether to disable fields set by CLI flags. Set to `false` to allow editing of these pre-populated fields. Defaults to `true`.
206    pub disable_prepopulated_fields: bool,
207    /// Whether to interpret escape sequences in replacement text. When enabled, `\n` becomes a newline,
208    /// `\t` becomes a tab, and `\\` becomes a literal backslash. Defaults to `false`.
209    pub interpret_escape_sequences: bool,
210}
211
212impl Default for SearchConfig {
213    fn default() -> Self {
214        Self {
215            disable_prepopulated_fields: true,
216            interpret_escape_sequences: false,
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use crate::commands::KeyMap;
224
225    use super::*;
226
227    #[test]
228    fn test_empty_config_file() -> anyhow::Result<()> {
229        let config: Config = toml::from_str("")?;
230        let default_config = Config::default();
231        assert_eq!(config, default_config);
232
233        Ok(())
234    }
235
236    #[test]
237    fn test_partial_config_editor_only() -> anyhow::Result<()> {
238        let config: Config = toml::from_str(
239            r#"
240[editor_open]
241command = "vim %file +%line"
242"#,
243        )?;
244
245        assert_eq!(
246            config.editor_open.command,
247            Some("vim %file +%line".to_string())
248        );
249        assert!(!config.editor_open.exit);
250
251        let default_preview = PreviewConfig::default();
252        assert_eq!(
253            config.preview.syntax_highlighting,
254            default_preview.syntax_highlighting
255        );
256
257        Ok(())
258    }
259
260    #[test]
261    fn test_partial_config_preview_only() -> anyhow::Result<()> {
262        let config: Config = toml::from_str(
263            r#"
264[preview]
265syntax_highlighting = false
266"#,
267        )?;
268
269        assert!(!config.preview.syntax_highlighting);
270        assert_eq!(
271            config.preview.syntax_highlighting_theme.name,
272            PreviewConfig::default().syntax_highlighting_theme.name
273        );
274
275        let default_editor_open = EditorOpenConfig::default();
276        assert_eq!(config.editor_open.command, default_editor_open.command);
277        assert_eq!(config.editor_open.exit, default_editor_open.exit);
278
279        Ok(())
280    }
281
282    #[test]
283    fn test_full_config() -> anyhow::Result<()> {
284        let config: Config = toml::from_str(
285            r#"
286[editor_open]
287command = "nvim %file +%line"
288exit = true
289
290[preview]
291syntax_highlighting = false
292syntax_highlighting_theme = "Solarized (light)"
293wrap_text = true
294
295[style]
296true_color = false
297
298[search]
299disable_prepopulated_fields = false
300interpret_escape_sequences = true
301"#,
302        )?;
303
304        assert_eq!(
305            config.editor_open.command,
306            Some("nvim %file +%line".to_string())
307        );
308        assert!(config.editor_open.exit);
309        assert!(!config.preview.syntax_highlighting);
310        assert_eq!(
311            config.preview.syntax_highlighting_theme.name,
312            Some("Solarized (light)".to_string())
313        );
314        assert_eq!(
315            config,
316            Config {
317                editor_open: EditorOpenConfig {
318                    command: Some("nvim %file +%line".to_owned()),
319                    exit: true,
320                },
321                preview: PreviewConfig {
322                    syntax_highlighting: false,
323                    syntax_highlighting_theme: load_theme("Solarized (light)").unwrap(),
324                    wrap_text: true,
325                },
326                style: StyleConfig { true_color: false },
327                search: SearchConfig {
328                    disable_prepopulated_fields: false,
329                    interpret_escape_sequences: true,
330                },
331                keys: KeysConfig::default(),
332            }
333        );
334
335        Ok(())
336    }
337
338    #[test]
339    fn test_missing_editor_exit_field() -> anyhow::Result<()> {
340        let config: Config = toml::from_str(
341            r#"
342[editor_open]
343command = "vim %file +%line"
344"#,
345        )?;
346
347        assert!(!config.editor_open.exit);
348        Ok(())
349    }
350
351    #[test]
352    fn test_get_theme_none() {
353        let config = Config::default();
354        assert_eq!(
355            config.get_theme(),
356            Some(&PreviewConfig::default().syntax_highlighting_theme)
357        );
358    }
359
360    #[test]
361    fn test_get_theme_disabled() {
362        let config = Config {
363            editor_open: EditorOpenConfig::default(),
364            preview: PreviewConfig {
365                syntax_highlighting: false,
366                syntax_highlighting_theme: load_theme("base16-ocean.dark").unwrap(),
367                wrap_text: false,
368            },
369            style: StyleConfig::default(),
370            search: SearchConfig::default(),
371            keys: KeysConfig::default(),
372        };
373        assert_eq!(config.get_theme(), None);
374    }
375
376    #[test]
377    fn test_get_theme_enabled_with_theme() {
378        let config = Config {
379            editor_open: EditorOpenConfig::default(),
380            preview: PreviewConfig {
381                syntax_highlighting: true,
382                syntax_highlighting_theme: load_theme("base16-ocean.dark").unwrap(),
383                wrap_text: false,
384            },
385            style: StyleConfig::default(),
386            search: SearchConfig::default(),
387            keys: KeysConfig::default(),
388        };
389        assert_eq!(
390            config.get_theme(),
391            Some(&load_theme("base16-ocean.dark").unwrap())
392        );
393    }
394
395    #[test]
396    fn test_unknown_keys_field_rejected() {
397        let result: Result<Config, _> = toml::from_str(
398            r#"
399[keys.search.this_doesnt_exist]
400trigger_search = "a"
401
402[keys.search.fields]
403trigger_search = "S-tab"
404"#,
405        );
406        assert!(result.is_err());
407        assert!(
408            result
409                .unwrap_err()
410                .to_string()
411                .contains("unknown field `this_doesnt_exist`")
412        );
413    }
414
415    #[test]
416    fn test_invalid_key_code_error_message() {
417        let result: Result<Config, _> = toml::from_str(
418            r#"
419[keys.search.fields]
420trigger_search = "C-Enter"
421"#,
422        );
423        assert!(result.is_err());
424        let error = result.unwrap_err().to_string();
425        insta::assert_snapshot!(error);
426    }
427
428    #[test]
429    fn test_invalid_key_modifier_error_message() {
430        let result: Result<Config, _> = toml::from_str(
431            r#"
432[keys.search.fields]
433trigger_search = "D-ret"
434"#,
435        );
436        assert!(result.is_err());
437        let error = result.unwrap_err().to_string();
438        insta::assert_snapshot!(error);
439    }
440
441    #[test]
442    fn test_key_conflict_within_same_section() {
443        let config: Config = toml::from_str(
444            r#"
445[keys.search.fields]
446trigger_search = "enter"
447focus_next_field = "enter"
448"#,
449        )
450        .unwrap();
451        let result = crate::commands::KeyMap::from_config(&config.keys);
452        assert!(result.is_err());
453        let conflicts = result.unwrap_err();
454        assert_eq!(conflicts.len(), 1);
455        assert_eq!(conflicts[0].context, "search.fields");
456        assert_eq!(conflicts[0].key.to_string(), "enter");
457    }
458
459    #[test]
460    fn test_key_conflict_with_multiple_mappings() {
461        let config: Config = toml::from_str(
462            r#"
463[keys.search.results]
464move_down = ["j", "down"]
465move_up = ["k", "down"]
466"#,
467        )
468        .unwrap();
469        let result = crate::commands::KeyMap::from_config(&config.keys);
470        assert!(result.is_err());
471        let conflicts = result.unwrap_err();
472        assert_eq!(conflicts.len(), 1);
473        assert_eq!(conflicts[0].context, "search.results");
474        assert_eq!(conflicts[0].key.to_string(), "down");
475    }
476
477    #[test]
478    fn test_no_conflict_between_different_screens() {
479        // Same key can be used in different screen contexts without conflict
480        let result: Result<Config, _> = toml::from_str(
481            r#"
482[keys.search.fields]
483trigger_search = "ret"
484
485[keys.results]
486quit = "ret"
487"#,
488        );
489        assert!(result.is_ok());
490    }
491
492    #[test]
493    fn test_no_conflict_between_general_and_screen_specific() {
494        // Screen-specific keys take priority, so no conflict
495        let result: Result<Config, _> = toml::from_str(
496            r#"
497[keys.general]
498quit = "q"
499
500[keys.results]
501quit = "q"
502"#,
503        );
504        assert!(result.is_ok());
505    }
506
507    #[test]
508    fn test_multiple_keys_same_command_no_conflict() {
509        let result: Result<Config, _> = toml::from_str(
510            r#"
511[keys.search.results]
512move_down = ["A-r", "l"]
513"#,
514        );
515        assert!(result.is_ok());
516    }
517
518    #[test]
519    fn test_all_default_keybindings_are_valid() {
520        let config = Config::default();
521        // If KeyMap construction succeeds, all defaults are valid
522        let key_map_result = KeyMap::from_config(&config.keys);
523        assert!(
524            key_map_result.is_ok(),
525            "Default keybindings should be valid: {:?}",
526            key_map_result.unwrap_err()
527        );
528    }
529
530    #[test]
531    fn test_uppercase_char_conflict_detection() {
532        let result: Result<Config, _> = toml::from_str(
533            r#"
534[keys.search.results]
535move_top = "R"
536move_bottom = "r"
537"#,
538        );
539        assert!(
540            result.is_ok(),
541            "r and R should be treated as different keys"
542        );
543    }
544}