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 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 pub command: Option<String>,
104 pub exit: bool,
106}
107
108#[derive(Debug, Deserialize, Clone, PartialEq)]
109#[serde(deny_unknown_fields, default)]
110pub struct PreviewConfig {
111 pub syntax_highlighting: bool,
113 #[serde(deserialize_with = "deserialize_syntax_highlighting_theme")]
127 pub syntax_highlighting_theme: Theme,
128 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 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#[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 pub disable_prepopulated_fields: bool,
207 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 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 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 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}