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, pinned_notes};
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&B\"] # Ctrl+B (leader gateway: Ctrl+B 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()
249 .with_ctrl()
250 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
251 .add(KeyStrike::KeyO, ActionShortcuts::OpenNote);
252
253 kb.batch_add()
257 .with_ctrl()
258 .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
260 .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
261 .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
262 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
268 .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
269 .add(KeyStrike::KeyB, ActionShortcuts::Leader)
276 .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
279 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
280 .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
281 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
282 .add(KeyStrike::KeyE, ActionShortcuts::OpenFileBrowser)
286 .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer)
287 .add(
292 crate::keys::default_yank_combo().key,
293 ActionShortcuts::YankRow,
294 );
295
296 kb.batch_add()
302 .add(KeyStrike::F4, ActionShortcuts::OpenPreferences);
303 kb.batch_add()
304 .with_ctrl()
305 .add(KeyStrike::Comma, ActionShortcuts::OpenPreferences);
306
307 kb.batch_add()
309 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
310
311 kb.batch_add()
312 .add(KeyStrike::F3, ActionShortcuts::OpenSavedSearches);
313
314 kb.batch_add().add(KeyStrike::F6, ActionShortcuts::OpenAsk);
316
317 kb.batch_add()
319 .add(KeyStrike::F5, ActionShortcuts::SwitchWorkspace);
320
321 kb.batch_add()
328 .with_ctrl()
329 .add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
330
331 kb
332}
333
334pub fn delete_artifacts(index: &IndexFile, history: &HistoryFile) -> Vec<String> {
348 let mut leftovers = Vec::new();
349 let stuck = index.remove();
353 if stuck.is_empty() {
354 tracing::info!("removed index {}", index);
355 }
356 for (path, e) in stuck {
357 tracing::warn!("failed to remove {}: {}", path, e);
358 leftovers.push(format!(" {path}\n {e}"));
359 }
360 if let Err(e) = history.remove() {
361 tracing::warn!("failed to remove history {}: {}", history, e);
362 leftovers.push(format!(" {history}\n {e}"));
363 } else {
364 tracing::info!("removed history {}", history);
365 }
366 leftovers
367}
368
369fn yes() -> bool {
370 true
371}
372
373fn default_autosave_interval() -> u64 {
374 5
375}
376
377fn default_leader_timeout_ms() -> u64 {
378 400
379}
380
381#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
383pub struct LeaderConfig {
384 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
387 pub bind: std::collections::BTreeMap<String, String>,
388 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
391 pub labels: std::collections::BTreeMap<String, String>,
392}
393
394impl AppSettings {
395 pub fn default_workspace_suggestion() -> Option<PathBuf> {
398 system::home()
399 .ok()
400 .map(|h| h.join("kimun-notes").into_path_buf())
401 }
402
403 pub fn leader_tree(&self) -> crate::keys::leader::LeaderNode {
407 let tree = crate::keys::leader::apply_overrides(
408 crate::keys::leader::leader_tree(),
409 self.leader
410 .bind
411 .iter()
412 .map(|(k, v)| (k.as_str(), v.as_str())),
413 );
414 crate::keys::leader::apply_labels(
415 tree,
416 self.leader
417 .labels
418 .iter()
419 .map(|(k, v)| (k.as_str(), v.as_str())),
420 )
421 }
422}
423
424fn default_cache_dir() -> PathBuf {
425 PathBuf::from(".")
426}
427
428fn default_history_dir() -> PathBuf {
429 PathBuf::from("history")
430}
431
432fn default_settings_base_dir() -> SystemPath {
444 system::app_dir().unwrap_or_else(|_| {
445 let fallback = std::env::temp_dir().join("kimun");
446 SystemPath::try_absolute(&fallback).unwrap_or_else(|_| system::log_dir())
447 })
448}
449
450fn default_cache_dir_resolved() -> SystemPath {
454 SystemPath::resolve(default_cache_dir(), &default_settings_base_dir())
455}
456
457fn default_history_dir_resolved() -> SystemPath {
459 SystemPath::resolve(default_history_dir(), &default_settings_base_dir())
460}
461
462fn default_use_nerd_fonts() -> bool {
463 false
464}
465
466fn default_sort_field() -> SortFieldSetting {
467 SortFieldSetting::Name
468}
469
470fn default_sort_order() -> SortOrderSetting {
471 SortOrderSetting::Ascending
472}
473
474fn default_journal_sort_field() -> SortFieldSetting {
475 SortFieldSetting::Name
476}
477
478fn default_journal_sort_order() -> SortOrderSetting {
479 SortOrderSetting::Descending
480}
481
482impl Default for AppSettings {
483 fn default() -> Self {
484 Self {
485 config_version: 0,
486 workspace_config: None,
487 theme: Default::default(),
488 cache_dir: default_cache_dir(),
489 cache_dir_resolved: default_cache_dir_resolved(),
490 history_dir: default_history_dir(),
491 history_dir_resolved: default_history_dir_resolved(),
492 needs_indexing: true,
493 key_bindings: default_keybindings(),
494 autosave_interval_secs: default_autosave_interval(),
495 leader_timeout_ms: default_leader_timeout_ms(),
496 leader: LeaderConfig::default(),
497 use_nerd_fonts: false,
498 editor_backend: EditorBackendSetting::Plain,
499 nvim_path: None,
500 default_sort_field: default_sort_field(),
501 default_sort_order: default_sort_order(),
502 journal_sort_field: default_journal_sort_field(),
503 journal_sort_order: default_journal_sort_order(),
504 group_directories: false,
505 ctrl_h: CtrlHSetting::default(),
506 config_file: None,
507 }
508 }
509}
510
511impl AppSettings {
512 pub fn theme_list(&self) -> Vec<Theme> {
513 let mut list = Theme::builtins();
514 list.append(&mut Self::load_custom_themes());
515 if let Ok(custom_default) = Self::load_default_theme() {
517 list.push(custom_default);
518 }
519 list.sort_by(|a, b| a.name.cmp(&b.name));
520 list
521 }
522
523 fn default_config_file_path() -> Result<PathBuf, SettingsError> {
524 Ok(system::ensure_app_dir()?
525 .join(BASE_CONFIG_FILE)
526 .into_path_buf())
527 }
528
529 fn get_config_file_path(&self) -> Result<PathBuf, SettingsError> {
530 if let Some(ref path) = self.config_file {
531 Ok(path.clone())
532 } else {
533 Self::default_config_file_path()
534 }
535 }
536
537 fn get_themes_path() -> Result<PathBuf, SettingsError> {
538 Ok(system::ensure_app_dir()?.join(THEMES_DIR).into_path_buf())
539 }
540
541 fn load_theme_from_path(path: &std::path::Path) -> Result<Theme, SettingsError> {
542 let theme_string = fs::read_to_string(path)?;
543 match toml::from_str::<Theme>(&theme_string) {
544 Ok(theme) => Ok(theme),
545 Err(e) => {
546 tracing::warn!("Skipping unparsable theme file {:?}: {}", path, e);
549 Err(SettingsError::CorruptTheme(e))
550 }
551 }
552 }
553
554 fn load_default_theme() -> Result<Theme, SettingsError> {
555 let theme_path = AppSettings::get_themes_path()?.join("default.toml");
556 Self::load_theme_from_path(&theme_path)
557 }
558
559 fn load_custom_themes() -> Vec<Theme> {
560 let mut themes = Vec::new();
561
562 let themes_path = match Self::get_themes_path() {
564 Ok(path) => path,
565 Err(_) => return themes,
566 };
567
568 let entries =
570 match SystemPath::try_absolute(&themes_path).and_then(|dir| system::read_dir(&dir)) {
571 Ok(entries) => entries,
572 Err(_) => return themes,
573 };
574
575 for entry in entries {
577 let path = entry.into_path_buf();
578
579 if !path.is_file() {
581 continue;
582 }
583
584 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
586 continue;
587 }
588
589 if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
591 continue;
592 }
593
594 match fs::read_to_string(&path)
596 .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
597 {
598 Ok(theme) => themes.push(theme),
599 Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
600 }
601 }
602
603 themes
604 }
605
606 pub fn update_check(&self) -> bool {
610 self.workspace_config
611 .as_ref()
612 .map(|wc| wc.global.update_check)
613 .unwrap_or(true)
614 }
615
616 pub fn mouse(&self) -> bool {
619 self.workspace_config
620 .as_ref()
621 .map(|wc| wc.global.mouse)
622 .unwrap_or(true)
623 }
624
625 pub fn save_to_disk(&self) -> Result<(), SettingsError> {
626 tracing::debug!("Saving settings to disk");
627 let settings_file_path = self.get_config_file_path()?;
628 let body = format!("{CONFIG_HEADER}{}", toml::to_string(&self)?);
632 system::replace_atomically(&settings_file_path, body.as_bytes())?;
633 Ok(())
634 }
635
636 pub fn load_from_disk() -> Result<Self, SettingsError> {
637 let settings_file_path = Self::default_config_file_path()?;
638
639 if !settings_file_path.exists() {
640 let default_settings = Self::defaults_for_config_file(settings_file_path);
641 default_settings.save_to_disk()?;
642 Ok(default_settings)
643 } else {
644 let mut settings_file = File::open(&settings_file_path)?;
645
646 let mut toml = String::new();
647 settings_file.read_to_string(&mut toml)?;
648
649 match toml::from_str::<AppSettings>(toml.as_ref()) {
650 Ok(mut setting) => {
651 setting.config_file = Some(settings_file_path.clone());
652 setting.resolve_paths(&Self::config_base_dir(&settings_file_path));
655 if config_migration::ConfigMigration::run(&mut setting)? {
656 setting.save_to_disk()?;
657 }
658 setting.merge_missing_default_bindings();
659 Ok(setting)
660 }
661 Err(e) => {
662 tracing::warn!(
663 "Config file at {:?} could not be parsed ({}). \
664 Renaming to .corrupt and starting with defaults.",
665 settings_file_path,
666 e
667 );
668 let corrupt_path = settings_file_path.with_extension("toml.corrupt");
669 let _ = system::move_file(&settings_file_path, &corrupt_path);
670 let defaults = Self::defaults_for_config_file(settings_file_path);
671 defaults.save_to_disk()?;
672 Ok(defaults)
673 }
674 }
675 }
676 }
677
678 pub fn load_from_file(path: PathBuf) -> Result<Self, SettingsError> {
679 if let Some(parent) = path.parent()
684 && !parent.as_os_str().is_empty()
685 {
686 system::create_dir(parent)?;
687 }
688 if !path.exists() {
689 let default_settings = Self::defaults_for_config_file(path);
690 default_settings.save_to_disk()?;
691 return Ok(default_settings);
692 }
693 let mut toml_str = String::new();
694 File::open(&path)?.read_to_string(&mut toml_str)?;
695 match toml::from_str::<AppSettings>(&toml_str) {
696 Ok(mut setting) => {
697 setting.config_file = Some(path.clone());
698
699 setting.resolve_paths(&Self::config_base_dir(&path));
702
703 if config_migration::ConfigMigration::run(&mut setting)? {
705 setting.save_to_disk()?;
706 }
707
708 setting.merge_missing_default_bindings();
709 Ok(setting)
710 }
711 Err(e) => {
712 tracing::warn!(
713 "Config file at {:?} could not be parsed ({}). \
714 Renaming to .corrupt and starting with defaults.",
715 path,
716 e
717 );
718 let corrupt_path = path.with_extension("toml.corrupt");
719 let _ = system::move_file(&path, &corrupt_path);
720 let defaults = Self::defaults_for_config_file(path);
721 defaults.save_to_disk()?;
722 Ok(defaults)
723 }
724 }
725 }
726
727 fn merge_missing_default_bindings(&mut self) {
733 let defaults = default_keybindings().to_hashmap();
734 let mut current = self.key_bindings.to_hashmap();
735 let mut bound: std::collections::HashSet<_> = current.values().flatten().cloned().collect();
736 for (action, combos) in defaults {
737 match current.entry(action) {
738 std::collections::hash_map::Entry::Vacant(e) => {
739 let free: Vec<_> = combos.into_iter().filter(|c| !bound.contains(c)).collect();
743 if !free.is_empty() {
744 bound.extend(free.iter().copied());
745 e.insert(free);
746 }
747 }
748 std::collections::hash_map::Entry::Occupied(mut e) => {
749 for combo in combos {
750 if !bound.contains(&combo) && !e.get().contains(&combo) {
751 bound.insert(combo);
752 e.get_mut().push(combo);
753 }
754 }
755 }
756 }
757 }
758 self.key_bindings = KeyBindings::from_hashmap(current);
759 }
760
761 pub fn set_workspace_path(&mut self, name: &str, workspace_path: PathBuf) {
766 let Some(entry) = self
767 .workspace_config
768 .as_mut()
769 .and_then(|wc| wc.workspaces.get_mut(name))
770 else {
771 return;
772 };
773 if *entry.effective_path() != workspace_path {
774 self.needs_indexing = true;
775 }
776 entry.path = workspace_path;
777 entry.resolved_path = None;
778 }
779
780 pub fn clear_workspace(&mut self) {
793 if let Some(wc) = &mut self.workspace_config {
794 let key = wc.global.current_workspace.clone();
795 if !key.is_empty() {
796 wc.workspaces.remove(&key);
797 }
798 wc.global.current_workspace = String::new();
799 }
800 }
801
802 pub fn resolve_workspace_path(&self) -> Option<SystemPath> {
805 let raw = self
806 .workspace_config
807 .as_ref()
808 .and_then(|wc| wc.get_current_workspace())
809 .map(|entry| entry.effective_path().clone())?;
810 match SystemPath::try_absolute(&raw) {
815 Ok(path) => Some(path),
816 Err(e) => {
817 tracing::warn!("ignoring unusable workspace path {raw:?}: {e}");
818 None
819 }
820 }
821 }
822
823 fn resolve_paths(&mut self, base: &SystemPath) {
827 if let Some(ref mut wc) = self.workspace_config {
829 for entry in wc.workspaces.values_mut() {
830 let resolved = SystemPath::resolve(&entry.path, base).into_path_buf();
831 if resolved != entry.path {
832 entry.resolved_path = Some(resolved);
833 }
834 }
835 }
836 self.cache_dir_resolved = SystemPath::resolve(&self.cache_dir, base);
837 self.history_dir_resolved = SystemPath::resolve(&self.history_dir, base);
838 }
839
840 fn defaults_for_config_file(path: PathBuf) -> Self {
851 let base = Self::config_base_dir(&path);
852 let mut settings = Self {
853 config_file: Some(path),
854 ..Self::default()
855 };
856 settings.resolve_paths(&base);
857 settings
858 }
859
860 fn config_base_dir(config_file: &std::path::Path) -> SystemPath {
878 let dir = config_file
879 .parent()
880 .filter(|p| !p.as_os_str().is_empty())
881 .unwrap_or(std::path::Path::new("."));
882 SystemPath::canonical(dir)
886 .or_else(|_| {
887 let cwd = std::env::current_dir().unwrap_or_default();
888 SystemPath::try_absolute(cwd.join(dir))
889 })
890 .unwrap_or_else(|_| default_settings_base_dir())
891 }
892
893 pub fn set_theme(&mut self, theme: String) {
894 self.theme = theme;
895 }
896
897 pub fn report_indexed(&mut self) {
898 self.needs_indexing = false;
899 }
900
901 pub fn needs_indexing(&self) -> bool {
902 self.needs_indexing
903 }
904
905 pub fn add_path_history(&self, note_path: &VaultPath) {
906 if !note_path.is_note() {
907 return;
908 }
909 let Some(history) = self.current_history() else {
910 return;
911 };
912 if let Err(e) = history.push(note_path) {
913 tracing::warn!("failed to write history {history}: {e}");
914 }
915 }
916
917 pub fn follow_rename_in_history(&self, from: &VaultPath, to: &VaultPath) {
921 self.edit_path_history(|paths| {
922 if from.is_note() {
923 pinned_notes::rewrite_note_rename(paths, from, to)
924 } else {
925 pinned_notes::rewrite_directory_rename(paths, from, to)
926 }
927 });
928 }
929
930 pub fn follow_delete_in_history(&self, path: &VaultPath) {
933 self.edit_path_history(|paths| {
934 if path.is_note() {
935 pinned_notes::remove(paths, path)
936 } else {
937 pinned_notes::remove_under_directory(paths, path)
938 }
939 });
940 }
941
942 pub fn edit_path_history(&self, f: impl FnOnce(&mut Vec<VaultPath>) -> bool) {
946 let Some(history) = self.current_history() else {
947 return;
948 };
949 if let Err(e) = history.edit(f) {
950 tracing::warn!("failed to write history {history}: {e}");
951 }
952 }
953
954 fn current_history(&self) -> Option<HistoryFile> {
956 self.current_workspace_name()
957 .map(|name| self.history_for(&name))
958 }
959
960 pub fn current_workspace_name(&self) -> Option<String> {
961 self.workspace_config
962 .as_ref()
963 .map(|wc| wc.global.current_workspace.clone())
964 .filter(|s| !s.is_empty())
965 }
966
967 pub fn cache_dir_resolved(&self) -> &SystemPath {
969 &self.cache_dir_resolved
970 }
971
972 pub fn history_dir_resolved(&self) -> &SystemPath {
974 &self.history_dir_resolved
975 }
976
977 fn file_key_for(&self, workspace_name: &str) -> String {
986 self.workspace_config
987 .as_ref()
988 .and_then(|wc| wc.get_workspace(workspace_name))
989 .map(|entry| entry.file_key_or(workspace_name))
990 .unwrap_or_else(|| workspace_name.to_string())
991 }
992
993 pub fn index_for(&self, workspace_name: &str) -> IndexFile {
1000 IndexFile::in_dir(&self.cache_dir_resolved, &self.file_key_for(workspace_name))
1001 }
1002
1003 pub fn history_for(&self, workspace_name: &str) -> HistoryFile {
1007 HistoryFile::in_dir(
1008 &self.history_dir_resolved,
1009 &self.file_key_for(workspace_name),
1010 )
1011 }
1012
1013 pub fn workspace_artifacts(&self, workspace_name: &str) -> (IndexFile, HistoryFile) {
1023 (
1024 self.index_for(workspace_name),
1025 self.history_for(workspace_name),
1026 )
1027 }
1028
1029 pub fn current_last_paths(&self) -> Vec<VaultPath> {
1031 self.current_history()
1032 .map(|history| history.load())
1033 .unwrap_or_default()
1034 }
1035
1036 #[cfg(test)]
1042 pub(crate) fn for_test_workspace(
1043 name: &str,
1044 workspace: &SystemPath,
1045 scratch_dir: SystemPath,
1046 ) -> Self {
1047 let mut wc = WorkspaceConfig::new_empty();
1048 wc.add_workspace(name.to_string(), workspace.clone().into_path_buf())
1049 .unwrap();
1050 Self {
1051 workspace_config: Some(wc),
1052 config_file: Some(scratch_dir.join("config.toml").into_path_buf()),
1053 history_dir_resolved: scratch_dir,
1054 ..Self::default()
1055 }
1056 }
1057
1058 #[cfg(test)]
1060 pub(crate) fn seed_test_history(&self, paths: &[&str]) {
1061 let paths: Vec<VaultPath> = paths.iter().map(|p| VaultPath::new(*p)).collect();
1062 self.current_history().unwrap().write(&paths).unwrap();
1063 }
1064
1065 pub fn icons(&self) -> icons::Icons {
1067 icons::Icons::new(self.use_nerd_fonts)
1068 }
1069
1070 pub fn yank_combos(&self) -> Vec<crate::keys::key_combo::KeyCombo> {
1074 self.key_bindings.combos_for(&ActionShortcuts::YankRow)
1075 }
1076
1077 pub fn effective_theme_name(&self) -> String {
1081 if self.theme.is_empty() {
1082 Theme::default().name
1083 } else {
1084 self.theme.clone()
1085 }
1086 }
1087
1088 pub fn get_theme(&self) -> Theme {
1094 let theme = if self.theme.is_empty() {
1095 Theme::default()
1096 } else {
1097 self.theme_list()
1098 .into_iter()
1099 .find(|t| t.name == self.theme)
1100 .unwrap_or_default()
1101 };
1102 theme.adapt_to_terminal()
1103 }
1104}
1105
1106#[cfg(test)]
1108mod test_paths {
1109 use std::path::PathBuf;
1110
1111 pub(super) fn absolute(unix_style: &str) -> PathBuf {
1123 let trimmed = unix_style.trim_start_matches('/');
1124 if cfg!(windows) {
1125 PathBuf::from(format!("C:\\{}", trimmed.replace('/', "\\")))
1126 } else {
1127 PathBuf::from(format!("/{trimmed}"))
1128 }
1129 }
1130
1131 pub(super) fn absolute_toml(unix_style: &str) -> String {
1134 absolute(unix_style).to_string_lossy().replace('\\', "\\\\")
1135 }
1136}
1137
1138#[cfg(test)]
1139#[allow(clippy::field_reassign_with_default)]
1140mod tests {
1141 use super::test_paths::absolute;
1142 use super::*;
1143
1144 #[test]
1145 fn default_workspace_suggestion_is_under_home() {
1146 let suggestion = AppSettings::default_workspace_suggestion();
1147 if let Some(p) = suggestion {
1148 assert!(p.ends_with("kimun-notes"));
1149 assert!(p.is_absolute());
1150 }
1151 }
1153
1154 #[test]
1155 fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
1156 let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
1159 let _ = std::fs::remove_file(&path); let result = AppSettings::load_theme_from_path(&path);
1162
1163 assert!(result.is_err(), "should return Err when file is absent");
1164 assert!(!path.exists(), "must not create the file as a side effect");
1165 }
1166
1167 #[test]
1168 fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
1169 let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
1171 std::fs::write(&path, b"not valid toml {{{{").unwrap();
1172
1173 let result = AppSettings::load_theme_from_path(&path);
1174
1175 assert!(result.is_err(), "should return Err for corrupt TOML");
1176 assert!(path.exists(), "corrupt theme file must not be deleted");
1179 std::fs::remove_file(&path).ok();
1180 }
1181
1182 #[test]
1183 fn default_keybindings_quit_matches_canonical_combo() {
1184 let kb = default_keybindings();
1185 let combo = crate::keys::default_quit_combo();
1186 assert_eq!(
1187 kb.get_action(&combo),
1188 Some(ActionShortcuts::Quit),
1189 "default_keybindings() must bind default_quit_combo() to Quit so the \
1190 deserialize safety net can recover an unreachable app"
1191 );
1192 }
1193
1194 #[test]
1195 fn autosave_interval_defaults_to_five() {
1196 let settings = AppSettings::default();
1197 assert_eq!(settings.autosave_interval_secs, 5);
1198 }
1199
1200 #[test]
1201 fn autosave_interval_deserializes_from_toml() {
1202 let toml = "autosave_interval_secs = 30\n";
1203 let settings: AppSettings = toml::from_str(toml).unwrap();
1204 assert_eq!(settings.autosave_interval_secs, 30);
1205 }
1206
1207 #[test]
1208 fn autosave_interval_defaults_when_missing_from_toml() {
1209 let toml = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
1211 assert_eq!(settings.autosave_interval_secs, 5);
1212 }
1213
1214 #[test]
1216 fn f2_file_operations_survives_toml_deserialize() {
1217 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1218 use crate::keys::key_strike::KeyStrike;
1219
1220 let toml = r#"
1221[key_bindings]
1222FileOperations = ["F2"]
1223"#;
1224 let settings: AppSettings = toml::from_str(toml).unwrap();
1225 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1226 let action = settings.key_bindings.get_action(&f2);
1227 assert_eq!(
1228 action,
1229 Some(ActionShortcuts::FileOperations),
1230 "F2 should survive deserialization and map to FileOperations"
1231 );
1232 }
1233
1234 #[test]
1236 fn merge_adds_f2_when_absent() {
1237 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1238 use crate::keys::key_strike::KeyStrike;
1239
1240 let toml = r#"
1242[key_bindings]
1243Quit = ["ctrl&Q"]
1244"#;
1245 let mut settings: AppSettings = toml::from_str(toml).unwrap();
1246 settings.merge_missing_default_bindings();
1247
1248 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1249 let action = settings.key_bindings.get_action(&f2);
1250 assert_eq!(
1251 action,
1252 Some(ActionShortcuts::FileOperations),
1253 "merge_missing_default_bindings should add F2 → FileOperations"
1254 );
1255 }
1256
1257 #[test]
1258 fn clear_workspace_removes_the_current_workspace_entry() {
1259 let mut settings = AppSettings::default();
1260 let mut wc = WorkspaceConfig::new_empty();
1261 wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1262 .unwrap();
1263 settings.workspace_config = Some(wc);
1264 assert_eq!(
1266 settings
1267 .workspace_config
1268 .as_ref()
1269 .unwrap()
1270 .global
1271 .current_workspace,
1272 "vault1"
1273 );
1274 settings.clear_workspace();
1275 let wc = settings.workspace_config.as_ref().unwrap();
1276 assert!(
1277 wc.workspaces.is_empty(),
1278 "workspace entry should be removed"
1279 );
1280 assert!(
1281 wc.global.current_workspace.is_empty(),
1282 "current_workspace should be empty"
1283 );
1284 }
1285
1286 #[test]
1287 fn clear_workspace_preserves_other_workspaces() {
1288 let mut settings = AppSettings::default();
1289 let mut wc = WorkspaceConfig::new_empty();
1290 wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1291 .unwrap();
1292 wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
1293 .unwrap();
1294 wc.global.current_workspace = "vault1".to_string();
1295 settings.workspace_config = Some(wc);
1296 settings.clear_workspace();
1297 let wc = settings.workspace_config.as_ref().unwrap();
1298 assert!(
1299 !wc.workspaces.contains_key("vault1"),
1300 "active workspace should be removed"
1301 );
1302 assert!(
1303 wc.workspaces.contains_key("vault2"),
1304 "other workspaces should be preserved"
1305 );
1306 assert!(
1307 wc.global.current_workspace.is_empty(),
1308 "current_workspace should be empty"
1309 );
1310 }
1311}
1312
1313#[cfg(test)]
1314mod backend_tests {
1315 use super::test_paths::{absolute, absolute_toml};
1316 use super::*;
1317
1318 #[test]
1319 fn a_config_still_saying_textarea_loads() {
1320 #[derive(serde::Deserialize)]
1324 struct Holder {
1325 editor_backend: EditorBackendSetting,
1326 }
1327 let old: Holder = toml::from_str("editor_backend = \"textarea\"").expect("still loads");
1328 assert_eq!(old.editor_backend, EditorBackendSetting::Plain);
1329 }
1330
1331 #[test]
1332 fn the_backend_is_written_back_as_plain() {
1333 let written = toml::to_string(&AppSettings::default()).expect("serialises");
1334 assert!(
1335 written.contains("editor_backend = \"plain\""),
1336 "a saved config should name the value as it is now: {written}"
1337 );
1338 }
1339
1340 #[test]
1341 fn default_backend_is_plain() {
1342 let settings = AppSettings::default();
1343 assert!(matches!(
1344 settings.editor_backend,
1345 EditorBackendSetting::Plain
1346 ));
1347 }
1348
1349 #[test]
1350 fn nvim_backend_round_trips_toml() {
1351 let toml = "editor_backend = \"nvim\"\n";
1352 let parsed: AppSettings = toml::from_str(toml).unwrap();
1353 assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
1354 }
1355
1356 #[test]
1357 fn editor_backend_vim_roundtrips_through_toml() {
1358 #[derive(serde::Serialize, serde::Deserialize)]
1359 struct W {
1360 editor_backend: EditorBackendSetting,
1361 }
1362 let w = W {
1363 editor_backend: EditorBackendSetting::Vim,
1364 };
1365 let s = toml::to_string(&w).unwrap();
1366 assert!(s.contains("editor_backend = \"vim\""), "serialized: {s}");
1367 let back: W = toml::from_str(&s).unwrap();
1368 assert_eq!(back.editor_backend, EditorBackendSetting::Vim);
1369 }
1370
1371 #[test]
1382 fn every_default_action_keeps_a_chord_a_legacy_terminal_can_send() {
1383 use crate::keys::reachability::{TerminalKeys, reach};
1384
1385 for (action, combos) in default_keybindings().to_hashmap() {
1386 let verdicts: Vec<String> = combos
1387 .iter()
1388 .map(|c| format!("{c}: {}", reach(*c, TerminalKeys::LEGACY)))
1389 .collect();
1390 assert!(
1391 combos
1392 .iter()
1393 .any(|c| reach(*c, TerminalKeys::LEGACY).is_ok()),
1394 "{action} has no chord a legacy terminal can send: {}",
1395 verdicts.join("; ")
1396 );
1397 }
1398 }
1399
1400 #[test]
1406 fn an_unreachable_chord_is_allowed_as_an_alias() {
1407 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1408 use crate::keys::reachability::{Reach, TerminalKeys, reach};
1409
1410 let kb = default_keybindings();
1411 let combos = kb.combos_for(&ActionShortcuts::OpenPreferences);
1412 let ctrl_comma = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::Comma);
1413 assert!(combos.contains(&ctrl_comma), "the alias is still bound");
1414 assert_eq!(
1415 reach(ctrl_comma, TerminalKeys::LEGACY),
1416 Reach::Untransmitted,
1417 "and it is still the unreachable one"
1418 );
1419 assert!(
1420 combos.contains(&KeyCombo::new(KeyModifiers::new(), KeyStrike::F4)),
1421 "F4 is what actually carries Preferences"
1422 );
1423 }
1424
1425 #[test]
1433 fn the_default_table_binds_no_text_action() {
1434 let kb = default_keybindings();
1435 let bound: Vec<String> = kb
1436 .to_hashmap()
1437 .keys()
1438 .filter_map(|a| match a {
1439 ActionShortcuts::Text(t) => Some(format!("{t:?}")),
1440 _ => None,
1441 })
1442 .collect();
1443 assert!(
1444 bound.is_empty(),
1445 "formatting belongs on the leader, but these still hold chords: {bound:?}"
1446 );
1447 }
1448
1449 #[test]
1455 fn ctrl_l_and_ctrl_t_belong_to_focus_and_the_drawer() {
1456 let kb = default_keybindings();
1457 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1458 let ctrl = |key| KeyCombo::new(KeyModifiers::new().and_ctrl(), key);
1459 assert_eq!(
1460 kb.get_action(&ctrl(KeyStrike::KeyL)),
1461 Some(ActionShortcuts::FocusEditor)
1462 );
1463 assert_eq!(
1464 kb.get_action(&ctrl(KeyStrike::KeyT)),
1465 Some(ActionShortcuts::ToggleSidebar)
1466 );
1467 }
1468
1469 #[test]
1472 fn ctrl_h_parses_and_defaults_to_auto() {
1473 for (written, expected) in [
1474 ("auto", CtrlHSetting::Auto),
1475 ("backspace", CtrlHSetting::Backspace),
1476 ("chord", CtrlHSetting::Chord),
1477 ] {
1478 let s = toml::from_str::<AppSettings>(&format!("ctrl_h = \"{written}\"\n"))
1479 .unwrap_or_else(|e| panic!("ctrl_h = \"{written}\" must parse: {e}"));
1480 assert_eq!(s.ctrl_h, expected);
1481 }
1482 assert_eq!(
1483 toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n")
1484 .unwrap()
1485 .ctrl_h,
1486 CtrlHSetting::Auto,
1487 "a config written before the setting existed must not change behaviour"
1488 );
1489 }
1490
1491 #[test]
1497 fn workspace_paths_are_absolute_without_resolve_paths() {
1498 for settings in [
1499 AppSettings::default(),
1500 toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n").unwrap(),
1501 ] {
1502 let cache = settings.index_for("w");
1503 let history = settings.history_for("w");
1504 assert!(
1505 cache.path().as_path().is_absolute(),
1506 "cache path not absolute: {cache}"
1507 );
1508 assert!(
1509 history.path().as_path().is_absolute(),
1510 "history path not absolute: {history}"
1511 );
1512 }
1513 }
1514
1515 #[test]
1519 fn resolve_paths_populates_resolved_path() {
1520 let base = tempfile::TempDir::new().unwrap();
1521 let notes = base.path().join("notes");
1522 std::fs::create_dir_all(¬es).unwrap();
1523
1524 let toml = r#"
1525config_version = 2
1526[global]
1527current_workspace = "test"
1528[workspaces.test]
1529path = "notes"
1530last_paths = []
1531created = "2026-01-01T00:00:00Z"
1532"#
1533 .to_string();
1534 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1535 settings.resolve_paths(&SystemPath::try_absolute(base.path()).unwrap());
1536
1537 let wc = settings.workspace_config.as_ref().unwrap();
1538 let entry = wc.workspaces.get("test").unwrap();
1539 assert_eq!(entry.path, PathBuf::from("notes"));
1541 assert!(entry.resolved_path.is_some());
1543 assert!(entry.effective_path().is_absolute());
1544 }
1545
1546 #[test]
1556 fn load_from_file_resolves_paths_for_a_config_that_does_not_exist_yet() {
1557 let dir = tempfile::TempDir::new().unwrap();
1558 let config_dir = dir.path().canonicalize().unwrap();
1559 let settings = AppSettings::load_from_file(config_dir.join("config.toml")).unwrap();
1560
1561 let cache = settings.index_for("work");
1562 let history = settings.history_for("work");
1563 assert!(
1564 cache.path().as_path().starts_with(&config_dir),
1565 "cache path must sit next to the config file, got {cache}"
1566 );
1567 assert!(
1568 history.path().as_path().starts_with(&config_dir),
1569 "history path must sit next to the config file, got {history}"
1570 );
1571 }
1572
1573 #[test]
1577 fn load_from_file_resolves_paths_when_the_config_is_corrupt() {
1578 let dir = tempfile::TempDir::new().unwrap();
1579 let config_dir = dir.path().canonicalize().unwrap();
1580 let config_path = config_dir.join("config.toml");
1581 std::fs::write(&config_path, "not = valid toml [[[").unwrap();
1582
1583 let settings = AppSettings::load_from_file(config_path).unwrap();
1584
1585 let cache = settings.index_for("work");
1586 assert!(
1587 cache.path().as_path().starts_with(&config_dir),
1588 "cache path must sit next to the config file, got {cache}"
1589 );
1590 }
1591
1592 #[test]
1593 fn resolve_paths_absolute_no_resolved_path() {
1594 let toml = format!(
1598 r#"
1599config_version = 2
1600[global]
1601current_workspace = "test"
1602[workspaces.test]
1603path = "{}"
1604last_paths = []
1605created = "2026-01-01T00:00:00Z"
1606"#,
1607 absolute_toml("/absolute/notes")
1608 );
1609 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1610 settings.resolve_paths(&SystemPath::try_absolute(absolute("/config")).unwrap());
1611
1612 let wc = settings.workspace_config.as_ref().unwrap();
1613 let entry = wc.workspaces.get("test").unwrap();
1614 assert!(entry.resolved_path.is_none());
1616 assert_eq!(*entry.effective_path(), absolute("/absolute/notes"));
1617 }
1618}
1619
1620#[cfg(test)]
1621mod sort_settings_tests {
1622 use super::*;
1623
1624 #[test]
1625 fn group_directories_defaults_off() {
1626 let s = AppSettings::default();
1627 assert!(!s.group_directories);
1628 }
1629
1630 #[test]
1631 fn open_sort_dialog_is_bound_by_default() {
1632 let s = AppSettings::default();
1633 let map = s.key_bindings.to_hashmap();
1634 assert!(
1635 map.contains_key(&ActionShortcuts::OpenSortDialog),
1636 "OpenSortDialog must have a default binding"
1637 );
1638 }
1639}