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 #[error(transparent)]
23 Io(#[from] std::io::Error),
24 #[error(transparent)]
26 HomeDir(#[from] etcetera::HomeDirError),
27 #[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 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 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
111impl 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 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 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
294fn 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
320fn 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
336fn 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
346pub 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 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
371pub 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 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 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 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 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 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 }
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 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}