use anyhow::anyhow;
use etcetera::base_strategy::{BaseStrategy, choose_base_strategy};
use serde::{Deserialize, Deserializer, de};
use std::{
fs,
path::{Path, PathBuf},
sync::OnceLock,
};
use two_face::re_exports::syntect::highlighting::{Theme, ThemeSet};
mod keys;
pub use keys::*;
pub const APP_NAME: &str = "scooter";
static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
fn get_theme_set() -> &'static ThemeSet {
THEME_SET.get_or_init(|| {
let mut themes = ThemeSet::load_defaults();
let theme_folder = themes_folder();
if theme_folder.exists() {
themes.add_from_folder(theme_folder).unwrap();
}
themes
})
}
static CONFIG_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
pub fn set_config_dir_override(dir: &Path) {
CONFIG_DIR_OVERRIDE
.set(dir.to_path_buf())
.expect("Config dir override should only be set once");
}
fn config_dir() -> PathBuf {
if let Some(dir) = CONFIG_DIR_OVERRIDE.get() {
return dir.clone();
}
let strategy = choose_base_strategy().expect("Unable to find config directory!");
strategy.config_dir().join(APP_NAME)
}
fn config_file() -> PathBuf {
config_dir().join("config.toml")
}
fn themes_folder() -> PathBuf {
config_dir().join("themes/")
}
#[derive(Debug, Default, Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub editor_open: EditorOpenConfig,
#[serde(default)]
pub preview: PreviewConfig,
#[serde(default)]
pub style: StyleConfig,
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub keys: KeysConfig,
}
impl Config {
pub fn get_theme(&self) -> Option<&Theme> {
if self.preview.syntax_highlighting {
Some(&self.preview.syntax_highlighting_theme)
} else {
None
}
}
}
pub fn load_config() -> anyhow::Result<Config> {
let config_file = &config_file();
if fs::exists(config_file)? {
let contents = fs::read_to_string(config_file)?;
let config = toml::from_str(&contents)?;
Ok(config)
} else {
Ok(Config::default())
}
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields, default)]
#[derive(Default)]
pub struct EditorOpenConfig {
pub command: Option<String>,
pub exit: bool,
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields, default)]
pub struct PreviewConfig {
pub syntax_highlighting: bool,
#[serde(deserialize_with = "deserialize_syntax_highlighting_theme")]
pub syntax_highlighting_theme: Theme,
pub wrap_text: bool,
}
impl Default for PreviewConfig {
fn default() -> Self {
Self {
syntax_highlighting: true,
syntax_highlighting_theme: load_theme("base16-eighties.dark").unwrap(),
wrap_text: false,
}
}
}
fn load_theme(theme_name: &str) -> anyhow::Result<Theme> {
let themes = get_theme_set();
match themes.themes.get(theme_name) {
Some(theme) => Ok(theme.clone()),
None => Err(anyhow!(
"Could not find theme {theme_name}, found {:?}",
themes.themes.keys()
)),
}
}
fn deserialize_syntax_highlighting_theme<'de, D>(deserializer: D) -> Result<Theme, D::Error>
where
D: Deserializer<'de>,
{
let theme_name = String::deserialize(deserializer)?;
load_theme(&theme_name).map_err(de::Error::custom)
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields, default)]
pub struct StyleConfig {
pub true_color: bool,
}
impl Default for StyleConfig {
fn default() -> Self {
Self {
true_color: detect_true_colour(),
}
}
}
#[cfg(windows)]
fn detect_true_colour() -> bool {
true
}
#[cfg(not(windows))]
fn detect_true_colour() -> bool {
if matches!(
std::env::var("COLORTERM").map(|v| matches!(v.as_str(), "truecolor" | "24bit")),
Ok(true)
) {
return true;
}
match termini::TermInfo::from_env() {
Ok(t) => {
t.extended_cap("RGB").is_some()
|| t.extended_cap("Tc").is_some()
|| (t.extended_cap("setrgbf").is_some() && t.extended_cap("setrgbb").is_some())
}
Err(_) => false,
}
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields, default)]
pub struct SearchConfig {
pub disable_prepopulated_fields: bool,
pub interpret_escape_sequences: bool,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
disable_prepopulated_fields: true,
interpret_escape_sequences: false,
}
}
}
#[cfg(test)]
mod tests {
use crate::commands::KeyMap;
use super::*;
#[test]
fn test_empty_config_file() -> anyhow::Result<()> {
let config: Config = toml::from_str("")?;
let default_config = Config::default();
assert_eq!(config, default_config);
Ok(())
}
#[test]
fn test_partial_config_editor_only() -> anyhow::Result<()> {
let config: Config = toml::from_str(
r#"
[editor_open]
command = "vim %file +%line"
"#,
)?;
assert_eq!(
config.editor_open.command,
Some("vim %file +%line".to_string())
);
assert!(!config.editor_open.exit);
let default_preview = PreviewConfig::default();
assert_eq!(
config.preview.syntax_highlighting,
default_preview.syntax_highlighting
);
Ok(())
}
#[test]
fn test_partial_config_preview_only() -> anyhow::Result<()> {
let config: Config = toml::from_str(
r#"
[preview]
syntax_highlighting = false
"#,
)?;
assert!(!config.preview.syntax_highlighting);
assert_eq!(
config.preview.syntax_highlighting_theme.name,
PreviewConfig::default().syntax_highlighting_theme.name
);
let default_editor_open = EditorOpenConfig::default();
assert_eq!(config.editor_open.command, default_editor_open.command);
assert_eq!(config.editor_open.exit, default_editor_open.exit);
Ok(())
}
#[test]
fn test_full_config() -> anyhow::Result<()> {
let config: Config = toml::from_str(
r#"
[editor_open]
command = "nvim %file +%line"
exit = true
[preview]
syntax_highlighting = false
syntax_highlighting_theme = "Solarized (light)"
wrap_text = true
[style]
true_color = false
[search]
disable_prepopulated_fields = false
interpret_escape_sequences = true
"#,
)?;
assert_eq!(
config.editor_open.command,
Some("nvim %file +%line".to_string())
);
assert!(config.editor_open.exit);
assert!(!config.preview.syntax_highlighting);
assert_eq!(
config.preview.syntax_highlighting_theme.name,
Some("Solarized (light)".to_string())
);
assert_eq!(
config,
Config {
editor_open: EditorOpenConfig {
command: Some("nvim %file +%line".to_owned()),
exit: true,
},
preview: PreviewConfig {
syntax_highlighting: false,
syntax_highlighting_theme: load_theme("Solarized (light)").unwrap(),
wrap_text: true,
},
style: StyleConfig { true_color: false },
search: SearchConfig {
disable_prepopulated_fields: false,
interpret_escape_sequences: true,
},
keys: KeysConfig::default(),
}
);
Ok(())
}
#[test]
fn test_missing_editor_exit_field() -> anyhow::Result<()> {
let config: Config = toml::from_str(
r#"
[editor_open]
command = "vim %file +%line"
"#,
)?;
assert!(!config.editor_open.exit);
Ok(())
}
#[test]
fn test_get_theme_none() {
let config = Config::default();
assert_eq!(
config.get_theme(),
Some(&PreviewConfig::default().syntax_highlighting_theme)
);
}
#[test]
fn test_get_theme_disabled() {
let config = Config {
editor_open: EditorOpenConfig::default(),
preview: PreviewConfig {
syntax_highlighting: false,
syntax_highlighting_theme: load_theme("base16-ocean.dark").unwrap(),
wrap_text: false,
},
style: StyleConfig::default(),
search: SearchConfig::default(),
keys: KeysConfig::default(),
};
assert_eq!(config.get_theme(), None);
}
#[test]
fn test_get_theme_enabled_with_theme() {
let config = Config {
editor_open: EditorOpenConfig::default(),
preview: PreviewConfig {
syntax_highlighting: true,
syntax_highlighting_theme: load_theme("base16-ocean.dark").unwrap(),
wrap_text: false,
},
style: StyleConfig::default(),
search: SearchConfig::default(),
keys: KeysConfig::default(),
};
assert_eq!(
config.get_theme(),
Some(&load_theme("base16-ocean.dark").unwrap())
);
}
#[test]
fn test_unknown_keys_field_rejected() {
let result: Result<Config, _> = toml::from_str(
r#"
[keys.search.this_doesnt_exist]
trigger_search = "a"
[keys.search.fields]
trigger_search = "S-tab"
"#,
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("unknown field `this_doesnt_exist`")
);
}
#[test]
fn test_invalid_key_code_error_message() {
let result: Result<Config, _> = toml::from_str(
r#"
[keys.search.fields]
trigger_search = "C-Enter"
"#,
);
assert!(result.is_err());
let error = result.unwrap_err().to_string();
insta::assert_snapshot!(error);
}
#[test]
fn test_invalid_key_modifier_error_message() {
let result: Result<Config, _> = toml::from_str(
r#"
[keys.search.fields]
trigger_search = "D-ret"
"#,
);
assert!(result.is_err());
let error = result.unwrap_err().to_string();
insta::assert_snapshot!(error);
}
#[test]
fn test_key_conflict_within_same_section() {
let config: Config = toml::from_str(
r#"
[keys.search.fields]
trigger_search = "enter"
focus_next_field = "enter"
"#,
)
.unwrap();
let result = crate::commands::KeyMap::from_config(&config.keys);
assert!(result.is_err());
let conflicts = result.unwrap_err();
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].context, "search.fields");
assert_eq!(conflicts[0].key.to_string(), "enter");
}
#[test]
fn test_key_conflict_with_multiple_mappings() {
let config: Config = toml::from_str(
r#"
[keys.search.results]
move_down = ["j", "down"]
move_up = ["k", "down"]
"#,
)
.unwrap();
let result = crate::commands::KeyMap::from_config(&config.keys);
assert!(result.is_err());
let conflicts = result.unwrap_err();
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].context, "search.results");
assert_eq!(conflicts[0].key.to_string(), "down");
}
#[test]
fn test_no_conflict_between_different_screens() {
let result: Result<Config, _> = toml::from_str(
r#"
[keys.search.fields]
trigger_search = "ret"
[keys.results]
quit = "ret"
"#,
);
assert!(result.is_ok());
}
#[test]
fn test_no_conflict_between_general_and_screen_specific() {
let result: Result<Config, _> = toml::from_str(
r#"
[keys.general]
quit = "q"
[keys.results]
quit = "q"
"#,
);
assert!(result.is_ok());
}
#[test]
fn test_multiple_keys_same_command_no_conflict() {
let result: Result<Config, _> = toml::from_str(
r#"
[keys.search.results]
move_down = ["A-r", "l"]
"#,
);
assert!(result.is_ok());
}
#[test]
fn test_all_default_keybindings_are_valid() {
let config = Config::default();
let key_map_result = KeyMap::from_config(&config.keys);
assert!(
key_map_result.is_ok(),
"Default keybindings should be valid: {:?}",
key_map_result.unwrap_err()
);
}
#[test]
fn test_uppercase_char_conflict_detection() {
let result: Result<Config, _> = toml::from_str(
r#"
[keys.search.results]
move_top = "R"
move_bottom = "r"
"#,
);
assert!(
result.is_ok(),
"r and R should be treated as different keys"
);
}
}