1use crate::keys::action_shortcuts::ActionShortcuts;
2use crate::keys::key_strike::KeyStrike;
3use crate::settings::themes::Theme;
4use crate::settings::workspace_config::WorkspaceConfig;
5use std::io::Read;
6use std::path::PathBuf;
7use std::sync::{Arc, RwLock};
8
9use std::fs::{self, File};
10
11#[derive(Debug, thiserror::Error)]
15pub enum SettingsError {
16 #[error(transparent)]
17 Io(#[from] std::io::Error),
18 #[error("cannot serialize settings: {0}")]
19 Serialize(#[from] toml::ser::Error),
20 #[error("corrupt theme file: {0}")]
21 CorruptTheme(toml::de::Error),
22 #[error("config migration failed: {0}")]
23 Migration(String),
24 #[error(transparent)]
25 System(#[from] kimun_core::system::SystemError),
26}
27
28pub type SharedSettings = Arc<RwLock<AppSettings>>;
30use kimun_core::IndexFile;
31
32use self::history::HistoryFile;
33use kimun_core::nfs::VaultPath;
34use kimun_core::system::{self, SystemPath};
35
36use crate::keys::KeyBindings;
37pub mod config_migration;
38pub mod history;
39pub mod icons;
40pub mod themes;
41pub mod workspace_config;
42
43#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum SortFieldSetting {
50 Name,
51 Title,
52}
53
54#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum SortOrderSetting {
57 Ascending,
58 Descending,
59}
60
61#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum EditorBackendSetting {
64 #[default]
71 #[serde(alias = "textarea")]
72 Plain,
73 Nvim,
74 Vim,
75}
76
77#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum CtrlHSetting {
82 #[default]
86 Auto,
87 Backspace,
91 Chord,
94}
95
96pub fn config_dir() -> Result<PathBuf, system::SystemError> {
102 system::ensure_app_dir().map(SystemPath::into_path_buf)
103}
104
105const BASE_CONFIG_FILE: &str = "config.toml";
106const THEMES_DIR: &str = "themes";
107
108const CONFIG_HEADER: &str = "\
109# ─── Kimün configuration ────────────────────────────────────────────────────
110#
111# KEY BINDINGS
112# ────────────
113# Supported combinations:
114# - ctrl and/or alt (with optional shift) + a letter (a-z)
115# - bare F-key (F1–F12, no modifier required)
116# Any combo that does not follow these rules is silently ignored when loaded.
117#
118# Format per action:
119# ActionName = [\"<modifiers> & <letter>\", ...]
120#
121# Available modifiers (combine with +): ctrl alt shift
122#
123# Examples:
124# Quit = [\"ctrl&Q\"] # Ctrl+Q
125# SearchNotes = [\"ctrl&K\"] # Ctrl+K
126# OpenNote = [\"ctrl&O\"] # Ctrl+O (fuzzy file finder)
127# OpenSettings = [\"F4\", \"ctrl&,\"] # F4 (Ctrl+, alias)
128# NewJournal = [\"ctrl&J\"] # Ctrl+J
129# FileOperations = [\"F2\"] # F2 (open file-ops menu: delete/rename/move)
130# Leader = [\"ctrl&G\"] # Ctrl+G (leader gateway: Ctrl+G f f, ...)
131# OpenCommandPalette = [\"ctrl&P\"] # Ctrl+P (every leader command, fuzzy)
132#
133# OTHER SETTINGS
134# ──────────────
135# theme = \"Gruvbox Dark\" # or any built-in / custom theme name
136# leader_timeout_ms = 400 # hesitation before the which-key menu
137# ctrl_h = \"auto\" # auto | backspace | chord
138# What a bare 0x08 byte means. Backspace and Ctrl-H are the same byte
139# on terminals without the kitty keyboard protocol, so only one of the
140# two can work there. \"auto\" keeps the Ctrl-H chord unless the tty's
141# erase character is 0x08; set \"backspace\" if your Backspace key moves
142# focus instead of deleting, \"chord\" to always keep the chord.
143#
144# LEADER TREE OVERRIDES
145# ─────────────────────
146# Remap, add, or remove leader sequences ([leader.bind]) and rename group
147# captions ([leader.labels]). Keys are the sequence AFTER the gateway;
148# bind values are action ids (see the cheatsheet) or \"none\" to unbind.
149# [leader.bind]
150# \"o f\" = \"find.files\" # remap: leader o f now opens the file picker
151# \"x\" = \"note.daily\" # add: leader x opens today's journal
152# \"g p\" = \"none\" # remove the git-sync stub binding
153# [leader.labels]
154# \"f\" = \"+search\" # rename the +find group caption
155#
156# ─────────────────────────────────────────────────────────────────────────────
157";
158
159#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
160pub struct AppSettings {
161 #[serde(default)]
163 pub config_version: u32,
164 #[serde(flatten, skip_serializing_if = "Option::is_none")]
165 pub workspace_config: Option<WorkspaceConfig>,
166
167 #[serde(default)]
169 pub theme: String,
170 #[serde(default = "default_cache_dir")]
172 pub cache_dir: PathBuf,
173 #[serde(skip, default = "default_cache_dir_resolved")]
179 cache_dir_resolved: SystemPath,
180
181 #[serde(default = "default_history_dir")]
183 pub history_dir: PathBuf,
184 #[serde(skip, default = "default_history_dir_resolved")]
186 history_dir_resolved: SystemPath,
187 #[serde(skip, default = "yes")]
188 needs_indexing: bool,
189 #[serde(default = "default_keybindings")]
190 pub key_bindings: KeyBindings,
191 #[serde(default = "default_autosave_interval")]
192 pub autosave_interval_secs: u64,
193 #[serde(default = "default_leader_timeout_ms")]
196 pub leader_timeout_ms: u64,
197 #[serde(default)]
201 pub leader: LeaderConfig,
202 #[serde(default = "default_use_nerd_fonts")]
203 pub use_nerd_fonts: bool,
204 #[serde(default)]
205 pub editor_backend: EditorBackendSetting,
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub nvim_path: Option<std::path::PathBuf>,
208 #[serde(default = "default_sort_field")]
209 pub default_sort_field: SortFieldSetting,
210 #[serde(default = "default_sort_order")]
211 pub default_sort_order: SortOrderSetting,
212 #[serde(default = "default_journal_sort_field")]
213 pub journal_sort_field: SortFieldSetting,
214 #[serde(default = "default_journal_sort_order")]
215 pub journal_sort_order: SortOrderSetting,
216 #[serde(default)]
217 pub group_directories: bool,
218 #[serde(default)]
222 pub ctrl_h: CtrlHSetting,
223 #[serde(skip)]
226 pub config_file: Option<PathBuf>,
227}
228
229fn default_keybindings() -> KeyBindings {
230 let mut kb = KeyBindings::empty();
231 kb.batch_add()
247 .with_ctrl()
248 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
249 .add(KeyStrike::KeyO, ActionShortcuts::OpenNote);
250
251 kb.batch_add()
255 .with_ctrl()
256 .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
259 .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
260 .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
261 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
268 .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
269 .add(KeyStrike::KeyG, ActionShortcuts::Leader)
272 .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
275 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
276 .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
277 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
278 .add(KeyStrike::KeyE, ActionShortcuts::OpenFileBrowser)
282 .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer)
283 .add(
288 crate::keys::default_yank_combo().key,
289 ActionShortcuts::YankRow,
290 );
291
292 kb.batch_add()
298 .add(KeyStrike::F4, ActionShortcuts::OpenPreferences);
299 kb.batch_add()
300 .with_ctrl()
301 .add(KeyStrike::Comma, ActionShortcuts::OpenPreferences);
302
303 kb.batch_add()
305 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
306
307 kb.batch_add()
308 .add(KeyStrike::F3, ActionShortcuts::OpenSavedSearches);
309
310 kb.batch_add().add(KeyStrike::F6, ActionShortcuts::OpenAsk);
312
313 kb.batch_add()
315 .add(KeyStrike::F5, ActionShortcuts::SwitchWorkspace);
316
317 kb.batch_add()
323 .with_ctrl()
324 .add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
325
326 kb
327}
328
329pub fn delete_artifacts(index: &IndexFile, history: &HistoryFile) -> Vec<String> {
343 let mut leftovers = Vec::new();
344 let stuck = index.remove();
348 if stuck.is_empty() {
349 tracing::info!("removed index {}", index);
350 }
351 for (path, e) in stuck {
352 tracing::warn!("failed to remove {}: {}", path, e);
353 leftovers.push(format!(" {path}\n {e}"));
354 }
355 if let Err(e) = history.remove() {
356 tracing::warn!("failed to remove history {}: {}", history, e);
357 leftovers.push(format!(" {history}\n {e}"));
358 } else {
359 tracing::info!("removed history {}", history);
360 }
361 leftovers
362}
363
364fn yes() -> bool {
365 true
366}
367
368fn default_autosave_interval() -> u64 {
369 5
370}
371
372fn default_leader_timeout_ms() -> u64 {
373 400
374}
375
376#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
378pub struct LeaderConfig {
379 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
382 pub bind: std::collections::BTreeMap<String, String>,
383 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
386 pub labels: std::collections::BTreeMap<String, String>,
387}
388
389impl AppSettings {
390 pub fn default_workspace_suggestion() -> Option<PathBuf> {
393 system::home()
394 .ok()
395 .map(|h| h.join("kimun-notes").into_path_buf())
396 }
397
398 pub fn leader_tree(&self) -> crate::keys::leader::LeaderNode {
402 let tree = crate::keys::leader::apply_overrides(
403 crate::keys::leader::leader_tree(),
404 self.leader
405 .bind
406 .iter()
407 .map(|(k, v)| (k.as_str(), v.as_str())),
408 );
409 crate::keys::leader::apply_labels(
410 tree,
411 self.leader
412 .labels
413 .iter()
414 .map(|(k, v)| (k.as_str(), v.as_str())),
415 )
416 }
417}
418
419fn default_cache_dir() -> PathBuf {
420 PathBuf::from(".")
421}
422
423fn default_history_dir() -> PathBuf {
424 PathBuf::from("history")
425}
426
427fn default_settings_base_dir() -> SystemPath {
439 system::app_dir().unwrap_or_else(|_| {
440 let fallback = std::env::temp_dir().join("kimun");
441 SystemPath::try_absolute(&fallback).unwrap_or_else(|_| system::log_dir())
442 })
443}
444
445fn default_cache_dir_resolved() -> SystemPath {
449 SystemPath::resolve(default_cache_dir(), &default_settings_base_dir())
450}
451
452fn default_history_dir_resolved() -> SystemPath {
454 SystemPath::resolve(default_history_dir(), &default_settings_base_dir())
455}
456
457fn default_use_nerd_fonts() -> bool {
458 false
459}
460
461fn default_sort_field() -> SortFieldSetting {
462 SortFieldSetting::Name
463}
464
465fn default_sort_order() -> SortOrderSetting {
466 SortOrderSetting::Ascending
467}
468
469fn default_journal_sort_field() -> SortFieldSetting {
470 SortFieldSetting::Name
471}
472
473fn default_journal_sort_order() -> SortOrderSetting {
474 SortOrderSetting::Descending
475}
476
477impl Default for AppSettings {
478 fn default() -> Self {
479 Self {
480 config_version: 0,
481 workspace_config: None,
482 theme: Default::default(),
483 cache_dir: default_cache_dir(),
484 cache_dir_resolved: default_cache_dir_resolved(),
485 history_dir: default_history_dir(),
486 history_dir_resolved: default_history_dir_resolved(),
487 needs_indexing: true,
488 key_bindings: default_keybindings(),
489 autosave_interval_secs: default_autosave_interval(),
490 leader_timeout_ms: default_leader_timeout_ms(),
491 leader: LeaderConfig::default(),
492 use_nerd_fonts: false,
493 editor_backend: EditorBackendSetting::Plain,
494 nvim_path: None,
495 default_sort_field: default_sort_field(),
496 default_sort_order: default_sort_order(),
497 journal_sort_field: default_journal_sort_field(),
498 journal_sort_order: default_journal_sort_order(),
499 group_directories: false,
500 ctrl_h: CtrlHSetting::default(),
501 config_file: None,
502 }
503 }
504}
505
506impl AppSettings {
507 pub fn theme_list(&self) -> Vec<Theme> {
508 let mut list = Theme::builtins();
509 list.append(&mut Self::load_custom_themes());
510 if let Ok(custom_default) = Self::load_default_theme() {
512 list.push(custom_default);
513 }
514 list.sort_by(|a, b| a.name.cmp(&b.name));
515 list
516 }
517
518 fn default_config_file_path() -> Result<PathBuf, SettingsError> {
519 Ok(system::ensure_app_dir()?
520 .join(BASE_CONFIG_FILE)
521 .into_path_buf())
522 }
523
524 fn get_config_file_path(&self) -> Result<PathBuf, SettingsError> {
525 if let Some(ref path) = self.config_file {
526 Ok(path.clone())
527 } else {
528 Self::default_config_file_path()
529 }
530 }
531
532 fn get_themes_path() -> Result<PathBuf, SettingsError> {
533 Ok(system::ensure_app_dir()?.join(THEMES_DIR).into_path_buf())
534 }
535
536 fn load_theme_from_path(path: &std::path::Path) -> Result<Theme, SettingsError> {
537 let theme_string = fs::read_to_string(path)?;
538 match toml::from_str::<Theme>(&theme_string) {
539 Ok(theme) => Ok(theme),
540 Err(e) => {
541 tracing::warn!("Skipping unparsable theme file {:?}: {}", path, e);
544 Err(SettingsError::CorruptTheme(e))
545 }
546 }
547 }
548
549 fn load_default_theme() -> Result<Theme, SettingsError> {
550 let theme_path = AppSettings::get_themes_path()?.join("default.toml");
551 Self::load_theme_from_path(&theme_path)
552 }
553
554 fn load_custom_themes() -> Vec<Theme> {
555 let mut themes = Vec::new();
556
557 let themes_path = match Self::get_themes_path() {
559 Ok(path) => path,
560 Err(_) => return themes,
561 };
562
563 let entries =
565 match SystemPath::try_absolute(&themes_path).and_then(|dir| system::read_dir(&dir)) {
566 Ok(entries) => entries,
567 Err(_) => return themes,
568 };
569
570 for entry in entries {
572 let path = entry.into_path_buf();
573
574 if !path.is_file() {
576 continue;
577 }
578
579 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
581 continue;
582 }
583
584 if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
586 continue;
587 }
588
589 match fs::read_to_string(&path)
591 .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
592 {
593 Ok(theme) => themes.push(theme),
594 Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
595 }
596 }
597
598 themes
599 }
600
601 pub fn update_check(&self) -> bool {
605 self.workspace_config
606 .as_ref()
607 .map(|wc| wc.global.update_check)
608 .unwrap_or(true)
609 }
610
611 pub fn mouse(&self) -> bool {
614 self.workspace_config
615 .as_ref()
616 .map(|wc| wc.global.mouse)
617 .unwrap_or(true)
618 }
619
620 pub fn save_to_disk(&self) -> Result<(), SettingsError> {
621 tracing::debug!("Saving settings to disk");
622 let settings_file_path = self.get_config_file_path()?;
623 let body = format!("{CONFIG_HEADER}{}", toml::to_string(&self)?);
627 system::replace_atomically(&settings_file_path, body.as_bytes())?;
628 Ok(())
629 }
630
631 pub fn load_from_disk() -> Result<Self, SettingsError> {
632 let settings_file_path = Self::default_config_file_path()?;
633
634 if !settings_file_path.exists() {
635 let default_settings = Self::defaults_for_config_file(settings_file_path);
636 default_settings.save_to_disk()?;
637 Ok(default_settings)
638 } else {
639 let mut settings_file = File::open(&settings_file_path)?;
640
641 let mut toml = String::new();
642 settings_file.read_to_string(&mut toml)?;
643
644 match toml::from_str::<AppSettings>(toml.as_ref()) {
645 Ok(mut setting) => {
646 setting.config_file = Some(settings_file_path.clone());
647 setting.resolve_paths(&Self::config_base_dir(&settings_file_path));
650 if config_migration::ConfigMigration::run(&mut setting)? {
651 setting.save_to_disk()?;
652 }
653 setting.merge_missing_default_bindings();
654 Ok(setting)
655 }
656 Err(e) => {
657 tracing::warn!(
658 "Config file at {:?} could not be parsed ({}). \
659 Renaming to .corrupt and starting with defaults.",
660 settings_file_path,
661 e
662 );
663 let corrupt_path = settings_file_path.with_extension("toml.corrupt");
664 let _ = system::move_file(&settings_file_path, &corrupt_path);
665 let defaults = Self::defaults_for_config_file(settings_file_path);
666 defaults.save_to_disk()?;
667 Ok(defaults)
668 }
669 }
670 }
671 }
672
673 pub fn load_from_file(path: PathBuf) -> Result<Self, SettingsError> {
674 if let Some(parent) = path.parent()
679 && !parent.as_os_str().is_empty()
680 {
681 system::create_dir(parent)?;
682 }
683 if !path.exists() {
684 let default_settings = Self::defaults_for_config_file(path);
685 default_settings.save_to_disk()?;
686 return Ok(default_settings);
687 }
688 let mut toml_str = String::new();
689 File::open(&path)?.read_to_string(&mut toml_str)?;
690 match toml::from_str::<AppSettings>(&toml_str) {
691 Ok(mut setting) => {
692 setting.config_file = Some(path.clone());
693
694 setting.resolve_paths(&Self::config_base_dir(&path));
697
698 if config_migration::ConfigMigration::run(&mut setting)? {
700 setting.save_to_disk()?;
701 }
702
703 setting.merge_missing_default_bindings();
704 Ok(setting)
705 }
706 Err(e) => {
707 tracing::warn!(
708 "Config file at {:?} could not be parsed ({}). \
709 Renaming to .corrupt and starting with defaults.",
710 path,
711 e
712 );
713 let corrupt_path = path.with_extension("toml.corrupt");
714 let _ = system::move_file(&path, &corrupt_path);
715 let defaults = Self::defaults_for_config_file(path);
716 defaults.save_to_disk()?;
717 Ok(defaults)
718 }
719 }
720 }
721
722 fn merge_missing_default_bindings(&mut self) {
728 let defaults = default_keybindings().to_hashmap();
729 let mut current = self.key_bindings.to_hashmap();
730 let mut bound: std::collections::HashSet<_> = current.values().flatten().cloned().collect();
731 for (action, combos) in defaults {
732 match current.entry(action) {
733 std::collections::hash_map::Entry::Vacant(e) => {
734 let free: Vec<_> = combos.into_iter().filter(|c| !bound.contains(c)).collect();
738 if !free.is_empty() {
739 bound.extend(free.iter().copied());
740 e.insert(free);
741 }
742 }
743 std::collections::hash_map::Entry::Occupied(mut e) => {
744 for combo in combos {
745 if !bound.contains(&combo) && !e.get().contains(&combo) {
746 bound.insert(combo);
747 e.get_mut().push(combo);
748 }
749 }
750 }
751 }
752 }
753 self.key_bindings = KeyBindings::from_hashmap(current);
754 }
755
756 pub fn set_workspace_path(&mut self, name: &str, workspace_path: PathBuf) {
761 let Some(entry) = self
762 .workspace_config
763 .as_mut()
764 .and_then(|wc| wc.workspaces.get_mut(name))
765 else {
766 return;
767 };
768 if *entry.effective_path() != workspace_path {
769 self.needs_indexing = true;
770 }
771 entry.path = workspace_path;
772 entry.resolved_path = None;
773 }
774
775 pub fn clear_workspace(&mut self) {
788 if let Some(wc) = &mut self.workspace_config {
789 let key = wc.global.current_workspace.clone();
790 if !key.is_empty() {
791 wc.workspaces.remove(&key);
792 }
793 wc.global.current_workspace = String::new();
794 }
795 }
796
797 pub fn resolve_workspace_path(&self) -> Option<SystemPath> {
800 let raw = self
801 .workspace_config
802 .as_ref()
803 .and_then(|wc| wc.get_current_workspace())
804 .map(|entry| entry.effective_path().clone())?;
805 match SystemPath::try_absolute(&raw) {
810 Ok(path) => Some(path),
811 Err(e) => {
812 tracing::warn!("ignoring unusable workspace path {raw:?}: {e}");
813 None
814 }
815 }
816 }
817
818 fn resolve_paths(&mut self, base: &SystemPath) {
822 if let Some(ref mut wc) = self.workspace_config {
824 for entry in wc.workspaces.values_mut() {
825 let resolved = SystemPath::resolve(&entry.path, base).into_path_buf();
826 if resolved != entry.path {
827 entry.resolved_path = Some(resolved);
828 }
829 }
830 }
831 self.cache_dir_resolved = SystemPath::resolve(&self.cache_dir, base);
832 self.history_dir_resolved = SystemPath::resolve(&self.history_dir, base);
833 }
834
835 fn defaults_for_config_file(path: PathBuf) -> Self {
846 let base = Self::config_base_dir(&path);
847 let mut settings = Self {
848 config_file: Some(path),
849 ..Self::default()
850 };
851 settings.resolve_paths(&base);
852 settings
853 }
854
855 fn config_base_dir(config_file: &std::path::Path) -> SystemPath {
873 let dir = config_file
874 .parent()
875 .filter(|p| !p.as_os_str().is_empty())
876 .unwrap_or(std::path::Path::new("."));
877 SystemPath::canonical(dir)
881 .or_else(|_| {
882 let cwd = std::env::current_dir().unwrap_or_default();
883 SystemPath::try_absolute(cwd.join(dir))
884 })
885 .unwrap_or_else(|_| default_settings_base_dir())
886 }
887
888 pub fn set_theme(&mut self, theme: String) {
889 self.theme = theme;
890 }
891
892 pub fn report_indexed(&mut self) {
893 self.needs_indexing = false;
894 }
895
896 pub fn needs_indexing(&self) -> bool {
897 self.needs_indexing
898 }
899
900 pub fn add_path_history(&mut self, note_path: &VaultPath) {
901 if !note_path.is_note() {
902 return;
903 }
904 let Some(workspace_name) = self.current_workspace_name() else {
905 return;
906 };
907 let history = self.history_for(&workspace_name);
908 if let Err(e) = history.push(note_path) {
909 tracing::warn!("failed to write history {history}: {e}");
910 }
911 }
912
913 pub fn current_workspace_name(&self) -> Option<String> {
914 self.workspace_config
915 .as_ref()
916 .map(|wc| wc.global.current_workspace.clone())
917 .filter(|s| !s.is_empty())
918 }
919
920 pub fn cache_dir_resolved(&self) -> &SystemPath {
922 &self.cache_dir_resolved
923 }
924
925 pub fn history_dir_resolved(&self) -> &SystemPath {
927 &self.history_dir_resolved
928 }
929
930 fn file_key_for(&self, workspace_name: &str) -> String {
939 self.workspace_config
940 .as_ref()
941 .and_then(|wc| wc.get_workspace(workspace_name))
942 .map(|entry| entry.file_key_or(workspace_name))
943 .unwrap_or_else(|| workspace_name.to_string())
944 }
945
946 pub fn index_for(&self, workspace_name: &str) -> IndexFile {
953 IndexFile::in_dir(&self.cache_dir_resolved, &self.file_key_for(workspace_name))
954 }
955
956 pub fn history_for(&self, workspace_name: &str) -> HistoryFile {
960 HistoryFile::in_dir(
961 &self.history_dir_resolved,
962 &self.file_key_for(workspace_name),
963 )
964 }
965
966 pub fn workspace_artifacts(&self, workspace_name: &str) -> (IndexFile, HistoryFile) {
976 (
977 self.index_for(workspace_name),
978 self.history_for(workspace_name),
979 )
980 }
981
982 pub fn current_last_paths(&self) -> Vec<VaultPath> {
984 let Some(name) = self.current_workspace_name() else {
985 return Vec::new();
986 };
987 self.history_for(&name).load()
988 }
989
990 pub fn icons(&self) -> icons::Icons {
992 icons::Icons::new(self.use_nerd_fonts)
993 }
994
995 pub fn yank_combos(&self) -> Vec<crate::keys::key_combo::KeyCombo> {
999 self.key_bindings.combos_for(&ActionShortcuts::YankRow)
1000 }
1001
1002 pub fn effective_theme_name(&self) -> String {
1006 if self.theme.is_empty() {
1007 Theme::default().name
1008 } else {
1009 self.theme.clone()
1010 }
1011 }
1012
1013 pub fn get_theme(&self) -> Theme {
1019 let theme = if self.theme.is_empty() {
1020 Theme::default()
1021 } else {
1022 self.theme_list()
1023 .into_iter()
1024 .find(|t| t.name == self.theme)
1025 .unwrap_or_default()
1026 };
1027 theme.adapt_to_terminal()
1028 }
1029}
1030
1031#[cfg(test)]
1033mod test_paths {
1034 use std::path::PathBuf;
1035
1036 pub(super) fn absolute(unix_style: &str) -> PathBuf {
1048 let trimmed = unix_style.trim_start_matches('/');
1049 if cfg!(windows) {
1050 PathBuf::from(format!("C:\\{}", trimmed.replace('/', "\\")))
1051 } else {
1052 PathBuf::from(format!("/{trimmed}"))
1053 }
1054 }
1055
1056 pub(super) fn absolute_toml(unix_style: &str) -> String {
1059 absolute(unix_style).to_string_lossy().replace('\\', "\\\\")
1060 }
1061}
1062
1063#[cfg(test)]
1064#[allow(clippy::field_reassign_with_default)]
1065mod tests {
1066 use super::test_paths::absolute;
1067 use super::*;
1068
1069 #[test]
1070 fn default_workspace_suggestion_is_under_home() {
1071 let suggestion = AppSettings::default_workspace_suggestion();
1072 if let Some(p) = suggestion {
1073 assert!(p.ends_with("kimun-notes"));
1074 assert!(p.is_absolute());
1075 }
1076 }
1078
1079 #[test]
1080 fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
1081 let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
1084 let _ = std::fs::remove_file(&path); let result = AppSettings::load_theme_from_path(&path);
1087
1088 assert!(result.is_err(), "should return Err when file is absent");
1089 assert!(!path.exists(), "must not create the file as a side effect");
1090 }
1091
1092 #[test]
1093 fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
1094 let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
1096 std::fs::write(&path, b"not valid toml {{{{").unwrap();
1097
1098 let result = AppSettings::load_theme_from_path(&path);
1099
1100 assert!(result.is_err(), "should return Err for corrupt TOML");
1101 assert!(path.exists(), "corrupt theme file must not be deleted");
1104 std::fs::remove_file(&path).ok();
1105 }
1106
1107 #[test]
1108 fn default_keybindings_quit_matches_canonical_combo() {
1109 let kb = default_keybindings();
1110 let combo = crate::keys::default_quit_combo();
1111 assert_eq!(
1112 kb.get_action(&combo),
1113 Some(ActionShortcuts::Quit),
1114 "default_keybindings() must bind default_quit_combo() to Quit so the \
1115 deserialize safety net can recover an unreachable app"
1116 );
1117 }
1118
1119 #[test]
1120 fn autosave_interval_defaults_to_five() {
1121 let settings = AppSettings::default();
1122 assert_eq!(settings.autosave_interval_secs, 5);
1123 }
1124
1125 #[test]
1126 fn autosave_interval_deserializes_from_toml() {
1127 let toml = "autosave_interval_secs = 30\n";
1128 let settings: AppSettings = toml::from_str(toml).unwrap();
1129 assert_eq!(settings.autosave_interval_secs, 30);
1130 }
1131
1132 #[test]
1133 fn autosave_interval_defaults_when_missing_from_toml() {
1134 let toml = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
1136 assert_eq!(settings.autosave_interval_secs, 5);
1137 }
1138
1139 #[test]
1141 fn f2_file_operations_survives_toml_deserialize() {
1142 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1143 use crate::keys::key_strike::KeyStrike;
1144
1145 let toml = r#"
1146[key_bindings]
1147FileOperations = ["F2"]
1148"#;
1149 let settings: AppSettings = toml::from_str(toml).unwrap();
1150 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1151 let action = settings.key_bindings.get_action(&f2);
1152 assert_eq!(
1153 action,
1154 Some(ActionShortcuts::FileOperations),
1155 "F2 should survive deserialization and map to FileOperations"
1156 );
1157 }
1158
1159 #[test]
1161 fn merge_adds_f2_when_absent() {
1162 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1163 use crate::keys::key_strike::KeyStrike;
1164
1165 let toml = r#"
1167[key_bindings]
1168Quit = ["ctrl&Q"]
1169"#;
1170 let mut settings: AppSettings = toml::from_str(toml).unwrap();
1171 settings.merge_missing_default_bindings();
1172
1173 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1174 let action = settings.key_bindings.get_action(&f2);
1175 assert_eq!(
1176 action,
1177 Some(ActionShortcuts::FileOperations),
1178 "merge_missing_default_bindings should add F2 → FileOperations"
1179 );
1180 }
1181
1182 #[test]
1183 fn clear_workspace_removes_the_current_workspace_entry() {
1184 let mut settings = AppSettings::default();
1185 let mut wc = WorkspaceConfig::new_empty();
1186 wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1187 .unwrap();
1188 settings.workspace_config = Some(wc);
1189 assert_eq!(
1191 settings
1192 .workspace_config
1193 .as_ref()
1194 .unwrap()
1195 .global
1196 .current_workspace,
1197 "vault1"
1198 );
1199 settings.clear_workspace();
1200 let wc = settings.workspace_config.as_ref().unwrap();
1201 assert!(
1202 wc.workspaces.is_empty(),
1203 "workspace entry should be removed"
1204 );
1205 assert!(
1206 wc.global.current_workspace.is_empty(),
1207 "current_workspace should be empty"
1208 );
1209 }
1210
1211 #[test]
1212 fn clear_workspace_preserves_other_workspaces() {
1213 let mut settings = AppSettings::default();
1214 let mut wc = WorkspaceConfig::new_empty();
1215 wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1216 .unwrap();
1217 wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
1218 .unwrap();
1219 wc.global.current_workspace = "vault1".to_string();
1220 settings.workspace_config = Some(wc);
1221 settings.clear_workspace();
1222 let wc = settings.workspace_config.as_ref().unwrap();
1223 assert!(
1224 !wc.workspaces.contains_key("vault1"),
1225 "active workspace should be removed"
1226 );
1227 assert!(
1228 wc.workspaces.contains_key("vault2"),
1229 "other workspaces should be preserved"
1230 );
1231 assert!(
1232 wc.global.current_workspace.is_empty(),
1233 "current_workspace should be empty"
1234 );
1235 }
1236}
1237
1238#[cfg(test)]
1239mod backend_tests {
1240 use super::test_paths::{absolute, absolute_toml};
1241 use super::*;
1242
1243 #[test]
1244 fn a_config_still_saying_textarea_loads() {
1245 #[derive(serde::Deserialize)]
1249 struct Holder {
1250 editor_backend: EditorBackendSetting,
1251 }
1252 let old: Holder = toml::from_str("editor_backend = \"textarea\"").expect("still loads");
1253 assert_eq!(old.editor_backend, EditorBackendSetting::Plain);
1254 }
1255
1256 #[test]
1257 fn the_backend_is_written_back_as_plain() {
1258 let written = toml::to_string(&AppSettings::default()).expect("serialises");
1259 assert!(
1260 written.contains("editor_backend = \"plain\""),
1261 "a saved config should name the value as it is now: {written}"
1262 );
1263 }
1264
1265 #[test]
1266 fn default_backend_is_plain() {
1267 let settings = AppSettings::default();
1268 assert!(matches!(
1269 settings.editor_backend,
1270 EditorBackendSetting::Plain
1271 ));
1272 }
1273
1274 #[test]
1275 fn nvim_backend_round_trips_toml() {
1276 let toml = "editor_backend = \"nvim\"\n";
1277 let parsed: AppSettings = toml::from_str(toml).unwrap();
1278 assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
1279 }
1280
1281 #[test]
1282 fn editor_backend_vim_roundtrips_through_toml() {
1283 #[derive(serde::Serialize, serde::Deserialize)]
1284 struct W {
1285 editor_backend: EditorBackendSetting,
1286 }
1287 let w = W {
1288 editor_backend: EditorBackendSetting::Vim,
1289 };
1290 let s = toml::to_string(&w).unwrap();
1291 assert!(s.contains("editor_backend = \"vim\""), "serialized: {s}");
1292 let back: W = toml::from_str(&s).unwrap();
1293 assert_eq!(back.editor_backend, EditorBackendSetting::Vim);
1294 }
1295
1296 #[test]
1307 fn every_default_action_keeps_a_chord_a_legacy_terminal_can_send() {
1308 use crate::keys::reachability::{TerminalKeys, reach};
1309
1310 for (action, combos) in default_keybindings().to_hashmap() {
1311 let verdicts: Vec<String> = combos
1312 .iter()
1313 .map(|c| format!("{c}: {}", reach(*c, TerminalKeys::LEGACY)))
1314 .collect();
1315 assert!(
1316 combos
1317 .iter()
1318 .any(|c| reach(*c, TerminalKeys::LEGACY).is_ok()),
1319 "{action} has no chord a legacy terminal can send: {}",
1320 verdicts.join("; ")
1321 );
1322 }
1323 }
1324
1325 #[test]
1331 fn an_unreachable_chord_is_allowed_as_an_alias() {
1332 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1333 use crate::keys::reachability::{Reach, TerminalKeys, reach};
1334
1335 let kb = default_keybindings();
1336 let combos = kb.combos_for(&ActionShortcuts::OpenPreferences);
1337 let ctrl_comma = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::Comma);
1338 assert!(combos.contains(&ctrl_comma), "the alias is still bound");
1339 assert_eq!(
1340 reach(ctrl_comma, TerminalKeys::LEGACY),
1341 Reach::Untransmitted,
1342 "and it is still the unreachable one"
1343 );
1344 assert!(
1345 combos.contains(&KeyCombo::new(KeyModifiers::new(), KeyStrike::F4)),
1346 "F4 is what actually carries Preferences"
1347 );
1348 }
1349
1350 #[test]
1358 fn the_default_table_binds_no_text_action() {
1359 let kb = default_keybindings();
1360 let bound: Vec<String> = kb
1361 .to_hashmap()
1362 .keys()
1363 .filter_map(|a| match a {
1364 ActionShortcuts::Text(t) => Some(format!("{t:?}")),
1365 _ => None,
1366 })
1367 .collect();
1368 assert!(
1369 bound.is_empty(),
1370 "formatting belongs on the leader, but these still hold chords: {bound:?}"
1371 );
1372 }
1373
1374 #[test]
1380 fn ctrl_l_and_ctrl_t_belong_to_focus_and_the_drawer() {
1381 let kb = default_keybindings();
1382 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1383 let ctrl = |key| KeyCombo::new(KeyModifiers::new().and_ctrl(), key);
1384 assert_eq!(
1385 kb.get_action(&ctrl(KeyStrike::KeyL)),
1386 Some(ActionShortcuts::FocusEditor)
1387 );
1388 assert_eq!(
1389 kb.get_action(&ctrl(KeyStrike::KeyT)),
1390 Some(ActionShortcuts::ToggleSidebar)
1391 );
1392 }
1393
1394 #[test]
1397 fn ctrl_h_parses_and_defaults_to_auto() {
1398 for (written, expected) in [
1399 ("auto", CtrlHSetting::Auto),
1400 ("backspace", CtrlHSetting::Backspace),
1401 ("chord", CtrlHSetting::Chord),
1402 ] {
1403 let s = toml::from_str::<AppSettings>(&format!("ctrl_h = \"{written}\"\n"))
1404 .unwrap_or_else(|e| panic!("ctrl_h = \"{written}\" must parse: {e}"));
1405 assert_eq!(s.ctrl_h, expected);
1406 }
1407 assert_eq!(
1408 toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n")
1409 .unwrap()
1410 .ctrl_h,
1411 CtrlHSetting::Auto,
1412 "a config written before the setting existed must not change behaviour"
1413 );
1414 }
1415
1416 #[test]
1422 fn workspace_paths_are_absolute_without_resolve_paths() {
1423 for settings in [
1424 AppSettings::default(),
1425 toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n").unwrap(),
1426 ] {
1427 let cache = settings.index_for("w");
1428 let history = settings.history_for("w");
1429 assert!(
1430 cache.path().as_path().is_absolute(),
1431 "cache path not absolute: {cache}"
1432 );
1433 assert!(
1434 history.path().as_path().is_absolute(),
1435 "history path not absolute: {history}"
1436 );
1437 }
1438 }
1439
1440 #[test]
1444 fn resolve_paths_populates_resolved_path() {
1445 let base = tempfile::TempDir::new().unwrap();
1446 let notes = base.path().join("notes");
1447 std::fs::create_dir_all(¬es).unwrap();
1448
1449 let toml = r#"
1450config_version = 2
1451[global]
1452current_workspace = "test"
1453[workspaces.test]
1454path = "notes"
1455last_paths = []
1456created = "2026-01-01T00:00:00Z"
1457"#
1458 .to_string();
1459 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1460 settings.resolve_paths(&SystemPath::try_absolute(base.path()).unwrap());
1461
1462 let wc = settings.workspace_config.as_ref().unwrap();
1463 let entry = wc.workspaces.get("test").unwrap();
1464 assert_eq!(entry.path, PathBuf::from("notes"));
1466 assert!(entry.resolved_path.is_some());
1468 assert!(entry.effective_path().is_absolute());
1469 }
1470
1471 #[test]
1481 fn load_from_file_resolves_paths_for_a_config_that_does_not_exist_yet() {
1482 let dir = tempfile::TempDir::new().unwrap();
1483 let config_dir = dir.path().canonicalize().unwrap();
1484 let settings = AppSettings::load_from_file(config_dir.join("config.toml")).unwrap();
1485
1486 let cache = settings.index_for("work");
1487 let history = settings.history_for("work");
1488 assert!(
1489 cache.path().as_path().starts_with(&config_dir),
1490 "cache path must sit next to the config file, got {cache}"
1491 );
1492 assert!(
1493 history.path().as_path().starts_with(&config_dir),
1494 "history path must sit next to the config file, got {history}"
1495 );
1496 }
1497
1498 #[test]
1502 fn load_from_file_resolves_paths_when_the_config_is_corrupt() {
1503 let dir = tempfile::TempDir::new().unwrap();
1504 let config_dir = dir.path().canonicalize().unwrap();
1505 let config_path = config_dir.join("config.toml");
1506 std::fs::write(&config_path, "not = valid toml [[[").unwrap();
1507
1508 let settings = AppSettings::load_from_file(config_path).unwrap();
1509
1510 let cache = settings.index_for("work");
1511 assert!(
1512 cache.path().as_path().starts_with(&config_dir),
1513 "cache path must sit next to the config file, got {cache}"
1514 );
1515 }
1516
1517 #[test]
1518 fn resolve_paths_absolute_no_resolved_path() {
1519 let toml = format!(
1523 r#"
1524config_version = 2
1525[global]
1526current_workspace = "test"
1527[workspaces.test]
1528path = "{}"
1529last_paths = []
1530created = "2026-01-01T00:00:00Z"
1531"#,
1532 absolute_toml("/absolute/notes")
1533 );
1534 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1535 settings.resolve_paths(&SystemPath::try_absolute(absolute("/config")).unwrap());
1536
1537 let wc = settings.workspace_config.as_ref().unwrap();
1538 let entry = wc.workspaces.get("test").unwrap();
1539 assert!(entry.resolved_path.is_none());
1541 assert_eq!(*entry.effective_path(), absolute("/absolute/notes"));
1542 }
1543}
1544
1545#[cfg(test)]
1546mod sort_settings_tests {
1547 use super::*;
1548
1549 #[test]
1550 fn group_directories_defaults_off() {
1551 let s = AppSettings::default();
1552 assert!(!s.group_directories);
1553 }
1554
1555 #[test]
1556 fn open_sort_dialog_is_bound_by_default() {
1557 let s = AppSettings::default();
1558 let map = s.key_bindings.to_hashmap();
1559 assert!(
1560 map.contains_key(&ActionShortcuts::OpenSortDialog),
1561 "OpenSortDialog must have a default binding"
1562 );
1563 }
1564}