Skip to main content

basalt_tui/config/
mod.rs

1mod env;
2mod key_binding;
3mod symbol;
4
5use core::fmt;
6use std::{collections::BTreeMap, fs::read_to_string};
7
8use etcetera::{choose_base_strategy, home_dir, BaseStrategy};
9use key_binding::KeyBinding;
10use serde::Deserialize;
11
12use crate::{app::Message, command::Command};
13
14pub(crate) use key_binding::{Key, Keystroke};
15pub(crate) use symbol::Symbols;
16
17#[derive(Debug, thiserror::Error)]
18pub enum ConfigError {
19    // Standard IO error, from [`std::io::Error`].
20    #[error(transparent)]
21    Io(#[from] std::io::Error),
22    // Occurs when the home directory cannot be located, from [`etcetera::HomeDirError`].
23    #[error(transparent)]
24    HomeDir(#[from] etcetera::HomeDirError),
25    /// TOML (De)serialization error, from [`toml::de::Error`].
26    #[error(transparent)]
27    Toml(#[from] toml::de::Error),
28    #[error("Invalid keybinding: {0}")]
29    InvalidKeybinding(String),
30    #[error("Unknown code: {0}")]
31    UnknownKeyCode(String),
32    #[error("Unknown modifiers: {0}")]
33    UnknownKeyModifiers(String),
34    #[error("User config not found: {0}")]
35    UserConfigNotFound(String),
36    #[error("Invalid config: {0}")]
37    InvalidConfig(String),
38}
39
40#[derive(Clone, Debug, PartialEq)]
41pub struct ConfigSection<'a> {
42    pub key_bindings: BTreeMap<String, Message<'a>>,
43}
44
45impl ConfigSection<'_> {
46    /// Takes self and another config and merges the `key_bindings` together overwriting the
47    /// existing entries with the value from another config.
48    pub(crate) fn merge_key_bindings(&mut self, config: Self) {
49        config.key_bindings.into_iter().for_each(|(key, message)| {
50            self.key_bindings.insert(key, message);
51        });
52    }
53
54    /// Replaces this section's key_bindings entirely with those from another config.
55    pub(crate) fn replace_key_bindings(&mut self, config: Self) {
56        if !config.key_bindings.is_empty() {
57            self.key_bindings = config.key_bindings;
58        }
59    }
60
61    pub fn sequence_to_message(&self, keys: &[Keystroke]) -> Option<Message<'_>> {
62        let s: String = keys.iter().map(|k| k.to_string()).collect();
63        self.key_bindings.get(&s).cloned()
64    }
65
66    pub fn is_sequence_prefix(&self, keys: &[Keystroke]) -> bool {
67        let s: String = keys.iter().map(|k| k.to_string()).collect();
68
69        self.key_bindings
70            .keys()
71            .any(|k| k.starts_with(&s) && k.len() > s.len())
72    }
73}
74
75impl fmt::Display for ConfigSection<'_> {
76    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
77        self.key_bindings
78            .iter()
79            .try_for_each(|(key, message)| -> fmt::Result { writeln!(f, "{key}: {message:?}") })?;
80
81        Ok(())
82    }
83}
84
85#[derive(Clone, Debug, PartialEq)]
86pub struct Config<'a> {
87    pub experimental_editor: bool,
88    pub vim_mode: bool,
89    pub symbols: Symbols,
90    pub global: ConfigSection<'a>,
91    pub splash: ConfigSection<'a>,
92    pub explorer: ConfigSection<'a>,
93    pub outline: ConfigSection<'a>,
94    pub input_modal: ConfigSection<'a>,
95    pub help_modal: ConfigSection<'a>,
96    pub note_editor: ConfigSection<'a>,
97    pub vault_selector_modal: ConfigSection<'a>,
98}
99
100impl Default for Config<'_> {
101    fn default() -> Self {
102        Self::from(TomlConfig::default())
103    }
104}
105
106impl From<TomlConfig> for Config<'_> {
107    fn from(value: TomlConfig) -> Self {
108        Self {
109            symbols: value.symbols.into(),
110            experimental_editor: value.experimental_editor,
111            vim_mode: value.vim_mode,
112            global: value.global.into(),
113            splash: value.splash.into(),
114            explorer: value.explorer.into(),
115            outline: value.outline.into(),
116            input_modal: value.input_modal.into(),
117            help_modal: value.help_modal.into(),
118            note_editor: value.note_editor.into(),
119            vault_selector_modal: value.vault_selector_modal.into(),
120        }
121    }
122}
123
124impl From<TomlConfigSection> for ConfigSection<'_> {
125    fn from(TomlConfigSection { key_bindings }: TomlConfigSection) -> Self {
126        Self {
127            key_bindings: key_bindings
128                .into_iter()
129                .map(|KeyBinding { key, command }| (key.to_string(), command.into()))
130                .collect(),
131        }
132    }
133}
134
135impl Config<'_> {
136    /// Takes self and another config and merges the `key_bindings` together overwriting the
137    /// existing entries with the value from another config.
138    pub(crate) fn merge(&mut self, config: Self) -> Self {
139        self.symbols = config.symbols;
140        self.experimental_editor = config.experimental_editor;
141        self.vim_mode = config.vim_mode;
142        self.global.merge_key_bindings(config.global);
143        self.explorer.merge_key_bindings(config.explorer);
144        self.splash.merge_key_bindings(config.splash);
145        self.outline.merge_key_bindings(config.outline);
146        self.input_modal.merge_key_bindings(config.input_modal);
147        self.note_editor.merge_key_bindings(config.note_editor);
148        self.help_modal.merge_key_bindings(config.help_modal);
149        self.vault_selector_modal
150            .merge_key_bindings(config.vault_selector_modal);
151        self.clone()
152    }
153
154    /// Replaces key_bindings for each section that has bindings defined in the given config.
155    /// Sections with no bindings in the given config are left unchanged.
156    pub(crate) fn replace(&mut self, config: Self) -> Self {
157        self.global.replace_key_bindings(config.global);
158        self.explorer.replace_key_bindings(config.explorer);
159        self.splash.replace_key_bindings(config.splash);
160        self.outline.replace_key_bindings(config.outline);
161        self.input_modal.replace_key_bindings(config.input_modal);
162        self.note_editor.replace_key_bindings(config.note_editor);
163        self.help_modal.replace_key_bindings(config.help_modal);
164        self.vault_selector_modal
165            .replace_key_bindings(config.vault_selector_modal);
166        self.clone()
167    }
168}
169
170impl fmt::Display for Config<'_> {
171    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
172        writeln!(f, "[global]\n{}", self.global)?;
173        writeln!(f, "[splash]\n{}", self.splash)?;
174        writeln!(f, "[explorer]\n{}", self.explorer)?;
175        writeln!(f, "[note_editor]\n{}", self.note_editor)?;
176        writeln!(f, "[help_modal]\n{}", self.help_modal)?;
177        writeln!(f, "[vault_selector_modal]\n{}", self.vault_selector_modal)?;
178
179        Ok(())
180    }
181}
182
183impl<'a> From<BTreeMap<String, Message<'a>>> for ConfigSection<'a> {
184    fn from(value: BTreeMap<String, Message<'a>>) -> Self {
185        Self {
186            key_bindings: value,
187        }
188    }
189}
190
191impl<'a, const N: usize> From<[(String, Message<'a>); N]> for ConfigSection<'a> {
192    fn from(value: [(String, Message<'a>); N]) -> Self {
193        BTreeMap::from(value).into()
194    }
195}
196
197#[derive(Clone, Debug, PartialEq, Deserialize, Default)]
198struct TomlConfigSection {
199    #[serde(default)]
200    key_bindings: KeyBindings,
201}
202
203#[derive(Clone, Debug, PartialEq, Deserialize, Default)]
204struct KeyBindings(Vec<KeyBinding>);
205
206impl IntoIterator for KeyBindings {
207    type Item = KeyBinding;
208    type IntoIter = std::vec::IntoIter<Self::Item>;
209
210    fn into_iter(self) -> Self::IntoIter {
211        self.0.into_iter()
212    }
213}
214
215impl AsRef<Vec<KeyBinding>> for KeyBindings {
216    fn as_ref(&self) -> &Vec<KeyBinding> {
217        &self.0
218    }
219}
220
221impl<const N: usize> From<[(Key, Command); N]> for KeyBindings {
222    fn from(value: [(Key, Command); N]) -> Self {
223        Self(value.into_iter().map(KeyBinding::from).collect())
224    }
225}
226
227#[derive(Clone, Debug, PartialEq, Deserialize, Default)]
228struct TomlConfig {
229    #[serde(default)]
230    symbols: symbol::TomlSymbols,
231    #[serde(default)]
232    experimental_editor: bool,
233    #[serde(default)]
234    vim_mode: bool,
235    #[serde(default)]
236    global: TomlConfigSection,
237    #[serde(default)]
238    splash: TomlConfigSection,
239    #[serde(default)]
240    explorer: TomlConfigSection,
241    #[serde(default)]
242    outline: TomlConfigSection,
243    #[serde(default)]
244    input_modal: TomlConfigSection,
245    #[serde(default)]
246    help_modal: TomlConfigSection,
247    #[serde(default)]
248    note_editor: TomlConfigSection,
249    #[serde(default)]
250    vault_selector_modal: TomlConfigSection,
251}
252
253/// Finds and reads the user configuration file in order of priority.
254///
255/// The function checks two standard locations:
256///
257/// 1. Directly under the user's home directory: `$HOME/.basalt.toml`
258/// 2. Under the user's config directory: `$HOME/.config/basalt/config.toml`
259///
260/// It first attempts to find the config file in the home directory. If not found, it then checks
261/// the config directory.
262fn read_user_config<'a>() -> Result<Config<'a>, ConfigError> {
263    let home_dir_path = home_dir().map(|home_dir| home_dir.join(".basalt.toml"));
264    let config_dir_path =
265        choose_base_strategy().map(|strategy| strategy.config_dir().join("basalt/config.toml"));
266
267    let config_path = [home_dir_path, config_dir_path]
268        .into_iter()
269        .flatten()
270        .find(|path| path.exists())
271        .ok_or(ConfigError::UserConfigNotFound(
272            "Could not find user config".to_string(),
273        ))?;
274
275    toml::from_str::<TomlConfig>(&read_to_string(config_path)?)
276        .map(Config::from)
277        .map_err(|err| ConfigError::InvalidConfig(err.message().to_string()))
278}
279
280const BASE_CONFIGURATION_STR: &str =
281    include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/config.toml"));
282
283const VIM_CONFIGURATION_STR: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vim.toml"));
284
285/// Loads and merges configuration from multiple sources in priority order.
286///
287/// The configuration is built by layering sources with increasing precedence:
288/// 1. Base configuration from embedded config.toml (lowest priority)
289/// 2. User-specific configuration from user's config directory
290/// 3. System overrides (Ctrl+C) that cannot be changed by users (highest priority)
291///
292/// # Configuration Precedence
293/// System overrides > User config > Base config
294pub fn load<'a>() -> Result<(Config<'a>, Vec<String>), ConfigError> {
295    // TODO: Use compile time toml parsing instead to check the build error during compile time
296    // Requires a custom proc-macro workspace crate
297    let mut config: Config = toml::from_str::<TomlConfig>(BASE_CONFIGURATION_STR)?.into();
298
299    if config.symbols.preset == symbol::Preset::Auto {
300        config.symbols.preset = symbol::detect_preset(env::SystemEnv)
301    }
302
303    let (user_config, warnings) = match read_user_config() {
304        Ok(config) => (Some(config), vec![]),
305        Err(ConfigError::UserConfigNotFound(_)) => (None, vec![]),
306        Err(err) => (None, vec![err.to_string()]),
307    };
308
309    if user_config.as_ref().is_some_and(|c| c.vim_mode) {
310        let vim_config: Config = toml::from_str::<TomlConfig>(VIM_CONFIGURATION_STR)
311            .map_err(ConfigError::from)?
312            .into();
313        config.replace(vim_config);
314    }
315
316    if let Some(user) = user_config {
317        config.merge(user);
318    }
319
320    let system_key_binding_overrides: ConfigSection =
321        [(Key::CTRL_C.to_string(), Message::Quit)].into();
322
323    config
324        .global
325        .merge_key_bindings(system_key_binding_overrides);
326
327    Ok((config, warnings))
328}
329
330#[cfg(test)]
331mod tests {
332    use ratatui::crossterm::event::KeyModifiers;
333    use similar_asserts::assert_eq;
334
335    use super::*;
336    // use insta::assert_snapshot;
337
338    #[test]
339    fn test_base_config_snapshot() {
340        // TODO: Does not work cross-platform as macOS has different names for the keys
341        // Potentially needs two snapshots
342        //
343        // let config: Config = toml::from_str::<TomlConfig>(BASE_CONFIGURATION_STR)
344        //     .unwrap()
345        //     .into();
346        //
347        // assert_snapshot!(format!("{:?}", config));
348    }
349
350    #[test]
351    fn test_config() {
352        use key_binding::Key;
353
354        let dummy_toml = r#"
355        [global]
356        key_bindings = [
357         { key = "q", command = "quit" },
358         { key = "ctrl+g", command = "vault_selector_modal_toggle" },
359         { key = "?", command = "help_modal_toggle" },
360        ]
361    "#;
362        let dummy_toml_config: TomlConfig = toml::from_str::<TomlConfig>(dummy_toml).unwrap();
363
364        let expected_toml_config = TomlConfig {
365            global: TomlConfigSection {
366                key_bindings: [
367                    (Key::from('q'), Command::Quit),
368                    (
369                        Key::from(('g', KeyModifiers::CONTROL)),
370                        Command::VaultSelectorModalToggle,
371                    ),
372                    (Key::from('?'), Command::HelpModalToggle),
373                ]
374                .into(),
375            },
376            ..Default::default()
377        };
378
379        assert_eq!(dummy_toml_config, expected_toml_config);
380
381        let expected_config = Config::default().merge(expected_toml_config.into());
382
383        assert_eq!(
384            Config::default().merge(Config::from(dummy_toml_config)),
385            expected_config
386        );
387    }
388}