Skip to main content

basalt_tui/config/
mod.rs

1mod env;
2mod key_binding;
3pub mod symbol;
4pub mod theme;
5
6use core::fmt;
7use std::{collections::BTreeMap, fs::read_to_string};
8
9use etcetera::{choose_base_strategy, home_dir, BaseStrategy};
10use key_binding::{KeyBinding, KeySpec, Leader};
11use serde::Deserialize;
12
13use crate::{app::Message, command::Command};
14
15pub(crate) use key_binding::{Key, Keystroke};
16pub(crate) use symbol::Symbols;
17pub(crate) use theme::Theme;
18
19#[derive(Debug, thiserror::Error)]
20pub enum ConfigError {
21    // Standard IO error, from [`std::io::Error`].
22    #[error(transparent)]
23    Io(#[from] std::io::Error),
24    // Occurs when the home directory cannot be located, from [`etcetera::HomeDirError`].
25    #[error(transparent)]
26    HomeDir(#[from] etcetera::HomeDirError),
27    /// TOML (De)serialization error, from [`toml::de::Error`].
28    #[error(transparent)]
29    Toml(#[from] toml::de::Error),
30    #[error("Invalid keybinding: {0}")]
31    InvalidKeybinding(String),
32    #[error("Unknown code: {0}")]
33    UnknownKeyCode(String),
34    #[error("Unknown modifiers: {0}")]
35    UnknownKeyModifiers(String),
36    #[error("User config not found: {0}")]
37    UserConfigNotFound(String),
38    #[error("Invalid config: {0}")]
39    InvalidConfig(String),
40}
41
42#[derive(Clone, Debug, PartialEq)]
43pub struct ConfigSection<'a> {
44    pub key_bindings: BTreeMap<String, Message<'a>>,
45}
46
47impl ConfigSection<'_> {
48    /// Takes self and another config and merges the `key_bindings` together overwriting the
49    /// existing entries with the value from another config.
50    pub(crate) fn merge_key_bindings(&mut self, config: Self) {
51        config.key_bindings.into_iter().for_each(|(key, message)| {
52            self.key_bindings.insert(key, message);
53        });
54    }
55
56    /// Replaces this section's key_bindings entirely with those from another config.
57    pub(crate) fn replace_key_bindings(&mut self, config: Self) {
58        if !config.key_bindings.is_empty() {
59            self.key_bindings = config.key_bindings;
60        }
61    }
62
63    pub fn sequence_to_message(&self, keys: &[Keystroke]) -> Option<Message<'_>> {
64        let s: String = keys.iter().map(|k| k.to_string()).collect();
65        self.key_bindings.get(&s).cloned()
66    }
67
68    pub fn is_sequence_prefix(&self, keys: &[Keystroke]) -> bool {
69        let s: String = keys.iter().map(|k| k.to_string()).collect();
70
71        self.key_bindings
72            .keys()
73            .any(|k| k.starts_with(&s) && k.len() > s.len())
74    }
75}
76
77impl fmt::Display for ConfigSection<'_> {
78    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
79        self.key_bindings
80            .iter()
81            .try_for_each(|(key, message)| -> fmt::Result { writeln!(f, "{key}: {message:?}") })?;
82
83        Ok(())
84    }
85}
86
87#[derive(Clone, Debug, PartialEq)]
88pub struct Config<'a> {
89    pub experimental_editor: bool,
90    pub vim_mode: bool,
91    pub symbols: Symbols,
92    pub theme: Theme,
93    pub global: ConfigSection<'a>,
94    pub splash: ConfigSection<'a>,
95    pub explorer: ConfigSection<'a>,
96    pub outline: ConfigSection<'a>,
97    pub input_modal: ConfigSection<'a>,
98    pub help_modal: ConfigSection<'a>,
99    pub note_editor: ConfigSection<'a>,
100    pub vault_selector_modal: ConfigSection<'a>,
101    pub debug_log_modal: ConfigSection<'a>,
102    pub theme_selector_modal: ConfigSection<'a>,
103}
104
105impl Default for Config<'_> {
106    fn default() -> Self {
107        Self::from(TomlConfig::default())
108    }
109}
110
111/// Resolves `<leader>` with the config's own leader. Layered sources are
112/// converted with [`Config::from_toml`] instead, so that the leader chosen by
113/// the user also applies to the bundled presets.
114impl From<TomlConfig> for Config<'_> {
115    fn from(value: TomlConfig) -> Self {
116        let leader = value.leader.clone();
117        Config::from_toml(value, &leader)
118    }
119}
120
121impl ConfigSection<'_> {
122    fn from_toml(TomlConfigSection { key_bindings }: TomlConfigSection, leader: &Leader) -> Self {
123        Self {
124            key_bindings: key_bindings
125                .into_iter()
126                .map(|KeyBinding { key, command }| {
127                    (key.resolve(leader).to_string(), command.into())
128                })
129                .collect(),
130        }
131    }
132}
133
134impl Config<'_> {
135    fn from_toml(value: TomlConfig, leader: &Leader) -> Self {
136        Self {
137            symbols: value.symbols.into(),
138            theme: theme::theme_by_name(value.theme.as_deref().unwrap_or("default")),
139            experimental_editor: value.experimental_editor,
140            vim_mode: value.vim_mode,
141            global: ConfigSection::from_toml(value.global, leader),
142            splash: ConfigSection::from_toml(value.splash, leader),
143            explorer: ConfigSection::from_toml(value.explorer, leader),
144            outline: ConfigSection::from_toml(value.outline, leader),
145            input_modal: ConfigSection::from_toml(value.input_modal, leader),
146            help_modal: ConfigSection::from_toml(value.help_modal, leader),
147            note_editor: ConfigSection::from_toml(value.note_editor, leader),
148            vault_selector_modal: ConfigSection::from_toml(value.vault_selector_modal, leader),
149            debug_log_modal: ConfigSection::from_toml(value.debug_log_modal, leader),
150            theme_selector_modal: ConfigSection::from_toml(value.theme_selector_modal, leader),
151        }
152    }
153
154    /// Takes self and another config and merges the `key_bindings` together overwriting the
155    /// existing entries with the value from another config.
156    pub(crate) fn merge(&mut self, config: Self) -> Self {
157        self.symbols = config.symbols;
158        self.theme = config.theme;
159        self.experimental_editor = config.experimental_editor;
160        self.vim_mode = config.vim_mode;
161        self.global.merge_key_bindings(config.global);
162        self.explorer.merge_key_bindings(config.explorer);
163        self.splash.merge_key_bindings(config.splash);
164        self.outline.merge_key_bindings(config.outline);
165        self.input_modal.merge_key_bindings(config.input_modal);
166        self.note_editor.merge_key_bindings(config.note_editor);
167        self.help_modal.merge_key_bindings(config.help_modal);
168        self.vault_selector_modal
169            .merge_key_bindings(config.vault_selector_modal);
170        self.debug_log_modal
171            .merge_key_bindings(config.debug_log_modal);
172        self.theme_selector_modal
173            .merge_key_bindings(config.theme_selector_modal);
174        self.clone()
175    }
176
177    /// Replaces key_bindings for each section that has bindings defined in the given config.
178    /// Sections with no bindings in the given config are left unchanged.
179    pub(crate) fn replace(&mut self, config: Self) -> Self {
180        self.global.replace_key_bindings(config.global);
181        self.explorer.replace_key_bindings(config.explorer);
182        self.splash.replace_key_bindings(config.splash);
183        self.outline.replace_key_bindings(config.outline);
184        self.input_modal.replace_key_bindings(config.input_modal);
185        self.note_editor.replace_key_bindings(config.note_editor);
186        self.help_modal.replace_key_bindings(config.help_modal);
187        self.vault_selector_modal
188            .replace_key_bindings(config.vault_selector_modal);
189        self.debug_log_modal
190            .replace_key_bindings(config.debug_log_modal);
191        self.theme_selector_modal
192            .replace_key_bindings(config.theme_selector_modal);
193        self.clone()
194    }
195}
196
197impl fmt::Display for Config<'_> {
198    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
199        writeln!(f, "[global]\n{}", self.global)?;
200        writeln!(f, "[splash]\n{}", self.splash)?;
201        writeln!(f, "[explorer]\n{}", self.explorer)?;
202        writeln!(f, "[note_editor]\n{}", self.note_editor)?;
203        writeln!(f, "[help_modal]\n{}", self.help_modal)?;
204        writeln!(f, "[vault_selector_modal]\n{}", self.vault_selector_modal)?;
205        writeln!(f, "[debug_log_modal]\n{}", self.debug_log_modal)?;
206
207        Ok(())
208    }
209}
210
211impl<'a> From<BTreeMap<String, Message<'a>>> for ConfigSection<'a> {
212    fn from(value: BTreeMap<String, Message<'a>>) -> Self {
213        Self {
214            key_bindings: value,
215        }
216    }
217}
218
219impl<'a, const N: usize> From<[(String, Message<'a>); N]> for ConfigSection<'a> {
220    fn from(value: [(String, Message<'a>); N]) -> Self {
221        BTreeMap::from(value).into()
222    }
223}
224
225#[derive(Clone, Debug, PartialEq, Deserialize, Default)]
226struct TomlConfigSection {
227    #[serde(default)]
228    key_bindings: KeyBindings,
229}
230
231#[derive(Clone, Debug, PartialEq, Deserialize, Default)]
232struct KeyBindings(Vec<KeyBinding>);
233
234impl IntoIterator for KeyBindings {
235    type Item = KeyBinding;
236    type IntoIter = std::vec::IntoIter<Self::Item>;
237
238    fn into_iter(self) -> Self::IntoIter {
239        self.0.into_iter()
240    }
241}
242
243impl AsRef<Vec<KeyBinding>> for KeyBindings {
244    fn as_ref(&self) -> &Vec<KeyBinding> {
245        &self.0
246    }
247}
248
249impl<const N: usize> From<[(Key, Command); N]> for KeyBindings {
250    fn from(value: [(Key, Command); N]) -> Self {
251        Self(
252            value
253                .into_iter()
254                .map(|(key, command)| KeyBinding::new(KeySpec::from(key), command))
255                .collect(),
256        )
257    }
258}
259
260#[derive(Clone, Debug, PartialEq, Deserialize, Default)]
261struct TomlConfig {
262    #[serde(default)]
263    symbols: symbol::TomlSymbols,
264    #[serde(default)]
265    theme: Option<String>,
266    #[serde(default)]
267    experimental_editor: bool,
268    #[serde(default)]
269    vim_mode: bool,
270    #[serde(default)]
271    leader: Leader,
272    #[serde(default)]
273    global: TomlConfigSection,
274    #[serde(default)]
275    splash: TomlConfigSection,
276    #[serde(default)]
277    explorer: TomlConfigSection,
278    #[serde(default)]
279    outline: TomlConfigSection,
280    #[serde(default)]
281    input_modal: TomlConfigSection,
282    #[serde(default)]
283    help_modal: TomlConfigSection,
284    #[serde(default)]
285    note_editor: TomlConfigSection,
286    #[serde(default)]
287    vault_selector_modal: TomlConfigSection,
288    #[serde(default)]
289    debug_log_modal: TomlConfigSection,
290    #[serde(default)]
291    theme_selector_modal: TomlConfigSection,
292}
293
294/// Finds and reads the user configuration file in order of priority.
295///
296/// The function checks two standard locations:
297///
298/// 1. Directly under the user's home directory: `$HOME/.basalt.toml`
299/// 2. Under the user's config directory: `$HOME/.config/basalt/config.toml`
300///
301/// It first attempts to find the config file in the home directory. If not found, it then checks
302/// the config directory.
303fn read_user_config() -> Result<TomlConfig, ConfigError> {
304    let home_dir_path = home_dir().map(|home_dir| home_dir.join(".basalt.toml"));
305    let config_dir_path =
306        choose_base_strategy().map(|strategy| strategy.config_dir().join("basalt/config.toml"));
307
308    let config_path = [home_dir_path, config_dir_path]
309        .into_iter()
310        .flatten()
311        .find(|path| path.exists())
312        .ok_or(ConfigError::UserConfigNotFound(
313            "Could not find user config".to_string(),
314        ))?;
315
316    toml::from_str::<TomlConfig>(&read_to_string(config_path)?)
317        .map_err(|err| ConfigError::InvalidConfig(err.message().to_string()))
318}
319
320/// The path the user config should be written to: an existing config if there
321/// is one, otherwise `$config/basalt/config.toml`.
322fn user_config_write_path() -> Option<std::path::PathBuf> {
323    let home = home_dir().ok().map(|home| home.join(".basalt.toml"));
324    let config = choose_base_strategy()
325        .ok()
326        .map(|strategy| strategy.config_dir().join("basalt/config.toml"));
327
328    [home.clone(), config.clone()]
329        .into_iter()
330        .flatten()
331        .find(|path| path.exists())
332        .or(config)
333        .or(home)
334}
335
336/// Sets the top-level `theme` key, preserving the rest of the config (comments,
337/// formatting and ordering) by editing the TOML document in place.
338fn upsert_theme(content: &str, name: &str) -> Result<String, ConfigError> {
339    let mut config = content
340        .parse::<toml_edit::DocumentMut>()
341        .map_err(|error| ConfigError::InvalidConfig(error.to_string()))?;
342    config["theme"] = toml_edit::value(name);
343    Ok(config.to_string())
344}
345
346/// Persists the chosen theme to the user config so it loads on the next run.
347pub fn save_theme(name: &str) -> Result<std::path::PathBuf, ConfigError> {
348    let path = user_config_write_path().ok_or(ConfigError::UserConfigNotFound(
349        "Could not determine a config location".to_string(),
350    ))?;
351
352    // Only a missing file is empty; a read that fails for any other reason must
353    // abort rather than clobber the user's existing config with just the theme.
354    let existing = match read_to_string(&path) {
355        Ok(content) => content,
356        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
357        Err(error) => return Err(error.into()),
358    };
359    if let Some(parent) = path.parent() {
360        std::fs::create_dir_all(parent)?;
361    }
362    std::fs::write(&path, upsert_theme(&existing, name)?)?;
363    Ok(path)
364}
365
366const BASE_CONFIGURATION_STR: &str =
367    include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/config.toml"));
368
369const VIM_CONFIGURATION_STR: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vim.toml"));
370
371/// Loads and merges configuration from multiple sources in priority order.
372///
373/// The configuration is built by layering sources with increasing precedence:
374/// 1. Base configuration from embedded config.toml (lowest priority)
375/// 2. User-specific configuration from user's config directory
376/// 3. System overrides (Ctrl+C) that cannot be changed by users (highest priority)
377///
378/// # Configuration Precedence
379/// System overrides > User config > Base config
380///
381/// The leader key is taken from the user config alone and applied to every
382/// layer, so `<leader>` means the same key in the bundled presets as it does in
383/// the user's own bindings.
384pub fn load<'a>() -> Result<(Config<'a>, Vec<String>), ConfigError> {
385    let (user_config, warnings) = match read_user_config() {
386        Ok(config) => (Some(config), vec![]),
387        Err(ConfigError::UserConfigNotFound(_)) => (None, vec![]),
388        Err(err) => (None, vec![err.to_string()]),
389    };
390
391    let leader = user_config
392        .as_ref()
393        .map(|user| user.leader.clone())
394        .unwrap_or_default();
395
396    // TODO: Use compile time toml parsing instead to check the build error during compile time
397    // Requires a custom proc-macro workspace crate
398    let mut config = Config::from_toml(
399        toml::from_str::<TomlConfig>(BASE_CONFIGURATION_STR)?,
400        &leader,
401    );
402
403    if config.symbols.preset == symbol::Preset::Auto {
404        config.symbols.preset = symbol::detect_preset(env::SystemEnv)
405    }
406
407    if user_config.as_ref().is_some_and(|user| user.vim_mode) {
408        let vim_config = toml::from_str::<TomlConfig>(VIM_CONFIGURATION_STR)
409            .map_err(ConfigError::from)
410            .map(|vim| Config::from_toml(vim, &leader))?;
411        config.replace(vim_config);
412    }
413
414    if let Some(user) = user_config {
415        config.merge(Config::from_toml(user, &leader));
416    }
417
418    let system_key_binding_overrides: ConfigSection =
419        [(Key::CTRL_C.to_string(), Message::Quit)].into();
420
421    config
422        .global
423        .merge_key_bindings(system_key_binding_overrides);
424
425    Ok((config, warnings))
426}
427
428#[cfg(test)]
429mod tests {
430    use std::slice;
431
432    use ratatui::crossterm::event::{KeyCode, KeyModifiers};
433    use similar_asserts::assert_eq;
434
435    use super::*;
436    // use insta::assert_snapshot;
437
438    fn theme_of(config: &str) -> Option<String> {
439        toml::from_str::<toml::Value>(config)
440            .unwrap()
441            .get("theme")
442            .and_then(toml::Value::as_str)
443            .map(str::to_string)
444    }
445
446    #[test]
447    fn upsert_theme_replaces_existing_key_and_keeps_comments() {
448        let updated =
449            upsert_theme("# keep me\ntheme = \"default\"\nvim_mode = true\n", "nord").unwrap();
450        assert_eq!(theme_of(&updated).as_deref(), Some("nord"));
451        assert!(updated.contains("# keep me"));
452        assert!(updated.contains("vim_mode = true"));
453    }
454
455    #[test]
456    fn upsert_theme_adds_missing_key_at_top_level() {
457        let updated = upsert_theme("vim_mode = false\n\n[global]\nkey = 1\n", "nord").unwrap();
458        let document = toml::from_str::<toml::Value>(&updated).unwrap();
459        assert_eq!(
460            document.get("theme").and_then(toml::Value::as_str),
461            Some("nord")
462        );
463        // The key must land top-level, not reparented under [global].
464        assert!(document["global"].get("theme").is_none());
465        assert_eq!(document["global"]["key"].as_integer(), Some(1));
466    }
467
468    #[test]
469    fn upsert_theme_ignores_commented_key() {
470        let updated = upsert_theme("# theme = \"default\"\n", "nord").unwrap();
471        assert_eq!(theme_of(&updated).as_deref(), Some("nord"));
472        assert!(updated.contains("# theme = \"default\""));
473    }
474
475    #[test]
476    fn upsert_theme_writes_into_empty_config() {
477        assert_eq!(
478            theme_of(&upsert_theme("", "nord").unwrap()).as_deref(),
479            Some("nord")
480        );
481    }
482
483    #[test]
484    fn upsert_theme_rejects_malformed_config() {
485        assert!(upsert_theme("this is = = not toml", "nord").is_err());
486    }
487
488    #[test]
489    fn test_base_config_parses() {
490        // Guards against a binding in the bundled config.toml that the key parser
491        // rejects, which would panic at startup via `load().unwrap()`.
492        toml::from_str::<TomlConfig>(BASE_CONFIGURATION_STR)
493            .map(Config::from)
494            .expect("bundled config.toml should parse");
495    }
496
497    #[test]
498    fn test_vim_config_parses() {
499        // Same guard for the vim overrides, which `load()` merges when vim mode
500        // is on and would otherwise only fail at startup for vim users.
501        toml::from_str::<TomlConfig>(VIM_CONFIGURATION_STR)
502            .map(Config::from)
503            .expect("bundled vim.toml should parse");
504    }
505
506    #[test]
507    fn test_base_config_snapshot() {
508        // TODO: Does not work cross-platform as macOS has different names for the keys
509        // Potentially needs two snapshots
510        //
511        // let config: Config = toml::from_str::<TomlConfig>(BASE_CONFIGURATION_STR)
512        //     .unwrap()
513        //     .into();
514        //
515        // assert_snapshot!(format!("{:?}", config));
516    }
517
518    #[test]
519    fn test_leader_key_bindings() {
520        let dummy_toml = r#"
521        leader = ","
522
523        [global]
524        key_bindings = [
525         { key = "<leader>q", command = "quit" },
526        ]
527    "#;
528        let config = Config::from(toml::from_str::<TomlConfig>(dummy_toml).unwrap());
529        let leader = Keystroke::from(KeyCode::Char(','));
530        let q = Keystroke::from(KeyCode::Char('q'));
531
532        assert!(config.global.is_sequence_prefix(slice::from_ref(&leader)));
533        assert_eq!(
534            config.global.sequence_to_message(&[leader, q]),
535            Some(Message::Quit)
536        );
537    }
538
539    #[test]
540    fn test_leader_applies_to_every_layer() {
541        // The user's leader has to reach the bundled presets too, otherwise a
542        // `<leader>` binding shipped with basalt would answer to a different key
543        // than the user's own bindings.
544        let leader = Leader::from(Key::from(','));
545        let preset = r#"
546        [explorer]
547        key_bindings = [
548         { key = "<leader>s", command = "explorer_sort" },
549        ]
550    "#;
551        let config = Config::from_toml(toml::from_str::<TomlConfig>(preset).unwrap(), &leader);
552        let keys = [
553            Keystroke::from(KeyCode::Char(',')),
554            Keystroke::from(KeyCode::Char('s')),
555        ];
556
557        assert_eq!(
558            config.explorer.sequence_to_message(&keys),
559            Some(Message::Explorer(crate::explorer::Message::Sort))
560        );
561    }
562
563    #[test]
564    fn test_config() {
565        use key_binding::Key;
566
567        let dummy_toml = r#"
568        [global]
569        key_bindings = [
570         { key = "q", command = "quit" },
571         { key = "ctrl+g", command = "vault_selector_modal_toggle" },
572         { key = "?", command = "help_modal_toggle" },
573        ]
574    "#;
575        let dummy_toml_config: TomlConfig = toml::from_str::<TomlConfig>(dummy_toml).unwrap();
576
577        let expected_toml_config = TomlConfig {
578            global: TomlConfigSection {
579                key_bindings: [
580                    (Key::from('q'), Command::Quit),
581                    (
582                        Key::from(('g', KeyModifiers::CONTROL)),
583                        Command::VaultSelectorModalToggle,
584                    ),
585                    (Key::from('?'), Command::HelpModalToggle),
586                ]
587                .into(),
588            },
589            ..Default::default()
590        };
591
592        assert_eq!(dummy_toml_config, expected_toml_config);
593
594        let expected_config = Config::default().merge(expected_toml_config.into());
595
596        assert_eq!(
597            Config::default().merge(Config::from(dummy_toml_config)),
598            expected_config
599        );
600    }
601}