1use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
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
77pub fn config_dir() -> Result<PathBuf, system::SystemError> {
83 system::ensure_app_dir().map(SystemPath::into_path_buf)
84}
85
86const BASE_CONFIG_FILE: &str = "config.toml";
87const THEMES_DIR: &str = "themes";
88
89const CONFIG_HEADER: &str = "\
90# ─── Kimün configuration ────────────────────────────────────────────────────
91#
92# KEY BINDINGS
93# ────────────
94# Supported combinations:
95# - ctrl and/or alt (with optional shift) + a letter (a-z)
96# - bare F-key (F1–F12, no modifier required)
97# Any combo that does not follow these rules is silently ignored when loaded.
98#
99# Format per action:
100# ActionName = [\"<modifiers> & <letter>\", ...]
101#
102# Available modifiers (combine with +): ctrl alt shift
103#
104# Examples:
105# Quit = [\"ctrl&Q\"] # Ctrl+Q
106# SearchNotes = [\"ctrl&K\"] # Ctrl+K
107# OpenNote = [\"ctrl&O\"] # Ctrl+O (fuzzy file finder)
108# OpenSettings = [\"F4\", \"ctrl&,\"] # F4 (Ctrl+, alias)
109# NewJournal = [\"ctrl&J\"] # Ctrl+J
110# FileOperations = [\"F2\"] # F2 (open file-ops menu: delete/rename/move)
111# Leader = [\"ctrl&G\"] # Ctrl+G (leader gateway: Ctrl+G f f, ...)
112# OpenCommandPalette = [\"ctrl&P\"] # Ctrl+P (every leader command, fuzzy)
113#
114# OTHER SETTINGS
115# ──────────────
116# theme = \"Gruvbox Dark\" # or any built-in / custom theme name
117# leader_timeout_ms = 400 # hesitation before the which-key menu
118#
119# LEADER TREE OVERRIDES
120# ─────────────────────
121# Remap, add, or remove leader sequences ([leader.bind]) and rename group
122# captions ([leader.labels]). Keys are the sequence AFTER the gateway;
123# bind values are action ids (see the cheatsheet) or \"none\" to unbind.
124# [leader.bind]
125# \"o f\" = \"find.files\" # remap: leader o f now opens the file picker
126# \"x\" = \"note.daily\" # add: leader x opens today's journal
127# \"g p\" = \"none\" # remove the git-sync stub binding
128# [leader.labels]
129# \"f\" = \"+search\" # rename the +find group caption
130#
131# ─────────────────────────────────────────────────────────────────────────────
132";
133
134#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
135pub struct AppSettings {
136 #[serde(default)]
138 pub config_version: u32,
139 #[serde(flatten, skip_serializing_if = "Option::is_none")]
140 pub workspace_config: Option<WorkspaceConfig>,
141
142 #[serde(default)]
144 pub theme: String,
145 #[serde(default = "default_cache_dir")]
147 pub cache_dir: PathBuf,
148 #[serde(skip, default = "default_cache_dir_resolved")]
154 cache_dir_resolved: SystemPath,
155
156 #[serde(default = "default_history_dir")]
158 pub history_dir: PathBuf,
159 #[serde(skip, default = "default_history_dir_resolved")]
161 history_dir_resolved: SystemPath,
162 #[serde(skip, default = "yes")]
163 needs_indexing: bool,
164 #[serde(default = "default_keybindings")]
165 pub key_bindings: KeyBindings,
166 #[serde(default = "default_autosave_interval")]
167 pub autosave_interval_secs: u64,
168 #[serde(default = "default_leader_timeout_ms")]
171 pub leader_timeout_ms: u64,
172 #[serde(default)]
176 pub leader: LeaderConfig,
177 #[serde(default = "default_use_nerd_fonts")]
178 pub use_nerd_fonts: bool,
179 #[serde(default)]
180 pub editor_backend: EditorBackendSetting,
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub nvim_path: Option<std::path::PathBuf>,
183 #[serde(default = "default_sort_field")]
184 pub default_sort_field: SortFieldSetting,
185 #[serde(default = "default_sort_order")]
186 pub default_sort_order: SortOrderSetting,
187 #[serde(default = "default_journal_sort_field")]
188 pub journal_sort_field: SortFieldSetting,
189 #[serde(default = "default_journal_sort_order")]
190 pub journal_sort_order: SortOrderSetting,
191 #[serde(default)]
192 pub group_directories: bool,
193 #[serde(skip)]
196 pub config_file: Option<PathBuf>,
197}
198
199fn default_keybindings() -> KeyBindings {
200 let mut kb = KeyBindings::empty();
201 kb.batch_add()
202 .with_ctrl()
203 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
204 .add(KeyStrike::KeyO, ActionShortcuts::OpenNote)
205 .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
206 .add(KeyStrike::KeyI, ActionShortcuts::Text(TextAction::Italic))
207 .add(
208 KeyStrike::KeyU,
209 ActionShortcuts::Text(TextAction::Underline),
210 )
211 .add(
212 KeyStrike::KeyS,
213 ActionShortcuts::Text(TextAction::Strikethrough),
214 )
215 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Link))
216 .add(
217 KeyStrike::KeyT,
218 ActionShortcuts::Text(TextAction::ToggleHeader),
219 )
220 .with_shift()
224 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
225
226 kb.batch_add()
230 .with_ctrl()
231 .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
234 .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
235 .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
236 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
240 .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
241 .add(KeyStrike::KeyG, ActionShortcuts::Leader)
244 .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
247 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
248 .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
249 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
250 .add(KeyStrike::KeyE, ActionShortcuts::OpenFileBrowser)
254 .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer)
255 .add(
260 crate::keys::default_yank_combo().key,
261 ActionShortcuts::YankRow,
262 );
263
264 kb.batch_add()
270 .add(KeyStrike::F4, ActionShortcuts::OpenPreferences);
271 kb.batch_add()
272 .with_ctrl()
273 .add(KeyStrike::Comma, ActionShortcuts::OpenPreferences);
274
275 kb.batch_add()
277 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
278
279 kb.batch_add()
280 .add(KeyStrike::F3, ActionShortcuts::OpenSavedSearches);
281
282 kb.batch_add().add(KeyStrike::F6, ActionShortcuts::OpenAsk);
284
285 kb.batch_add()
287 .add(KeyStrike::F5, ActionShortcuts::SwitchWorkspace);
288
289 kb.batch_add()
294 .with_ctrl()
295 .add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
296
297 kb
298}
299
300pub fn delete_artifacts(index: &IndexFile, history: &HistoryFile) -> Vec<String> {
314 let mut leftovers = Vec::new();
315 let stuck = index.remove();
319 if stuck.is_empty() {
320 tracing::info!("removed index {}", index);
321 }
322 for (path, e) in stuck {
323 tracing::warn!("failed to remove {}: {}", path, e);
324 leftovers.push(format!(" {path}\n {e}"));
325 }
326 if let Err(e) = history.remove() {
327 tracing::warn!("failed to remove history {}: {}", history, e);
328 leftovers.push(format!(" {history}\n {e}"));
329 } else {
330 tracing::info!("removed history {}", history);
331 }
332 leftovers
333}
334
335fn yes() -> bool {
336 true
337}
338
339fn default_autosave_interval() -> u64 {
340 5
341}
342
343fn default_leader_timeout_ms() -> u64 {
344 400
345}
346
347#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
349pub struct LeaderConfig {
350 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
353 pub bind: std::collections::BTreeMap<String, String>,
354 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
357 pub labels: std::collections::BTreeMap<String, String>,
358}
359
360impl AppSettings {
361 pub fn default_workspace_suggestion() -> Option<PathBuf> {
364 system::home()
365 .ok()
366 .map(|h| h.join("kimun-notes").into_path_buf())
367 }
368
369 pub fn leader_tree(&self) -> crate::keys::leader::LeaderNode {
373 let tree = crate::keys::leader::apply_overrides(
374 crate::keys::leader::leader_tree(),
375 self.leader
376 .bind
377 .iter()
378 .map(|(k, v)| (k.as_str(), v.as_str())),
379 );
380 crate::keys::leader::apply_labels(
381 tree,
382 self.leader
383 .labels
384 .iter()
385 .map(|(k, v)| (k.as_str(), v.as_str())),
386 )
387 }
388}
389
390fn default_cache_dir() -> PathBuf {
391 PathBuf::from(".")
392}
393
394fn default_history_dir() -> PathBuf {
395 PathBuf::from("history")
396}
397
398fn default_settings_base_dir() -> SystemPath {
410 system::app_dir().unwrap_or_else(|_| {
411 let fallback = std::env::temp_dir().join("kimun");
412 SystemPath::try_absolute(&fallback).unwrap_or_else(|_| system::log_dir())
413 })
414}
415
416fn default_cache_dir_resolved() -> SystemPath {
420 SystemPath::resolve(default_cache_dir(), &default_settings_base_dir())
421}
422
423fn default_history_dir_resolved() -> SystemPath {
425 SystemPath::resolve(default_history_dir(), &default_settings_base_dir())
426}
427
428fn default_use_nerd_fonts() -> bool {
429 false
430}
431
432fn default_sort_field() -> SortFieldSetting {
433 SortFieldSetting::Name
434}
435
436fn default_sort_order() -> SortOrderSetting {
437 SortOrderSetting::Ascending
438}
439
440fn default_journal_sort_field() -> SortFieldSetting {
441 SortFieldSetting::Name
442}
443
444fn default_journal_sort_order() -> SortOrderSetting {
445 SortOrderSetting::Descending
446}
447
448impl Default for AppSettings {
449 fn default() -> Self {
450 Self {
451 config_version: 0,
452 workspace_config: None,
453 theme: Default::default(),
454 cache_dir: default_cache_dir(),
455 cache_dir_resolved: default_cache_dir_resolved(),
456 history_dir: default_history_dir(),
457 history_dir_resolved: default_history_dir_resolved(),
458 needs_indexing: true,
459 key_bindings: default_keybindings(),
460 autosave_interval_secs: default_autosave_interval(),
461 leader_timeout_ms: default_leader_timeout_ms(),
462 leader: LeaderConfig::default(),
463 use_nerd_fonts: false,
464 editor_backend: EditorBackendSetting::Plain,
465 nvim_path: None,
466 default_sort_field: default_sort_field(),
467 default_sort_order: default_sort_order(),
468 journal_sort_field: default_journal_sort_field(),
469 journal_sort_order: default_journal_sort_order(),
470 group_directories: false,
471 config_file: None,
472 }
473 }
474}
475
476impl AppSettings {
477 pub fn theme_list(&self) -> Vec<Theme> {
478 let mut list = Theme::builtins();
479 list.append(&mut Self::load_custom_themes());
480 if let Ok(custom_default) = Self::load_default_theme() {
482 list.push(custom_default);
483 }
484 list.sort_by(|a, b| a.name.cmp(&b.name));
485 list
486 }
487
488 fn default_config_file_path() -> Result<PathBuf, SettingsError> {
489 Ok(system::ensure_app_dir()?
490 .join(BASE_CONFIG_FILE)
491 .into_path_buf())
492 }
493
494 fn get_config_file_path(&self) -> Result<PathBuf, SettingsError> {
495 if let Some(ref path) = self.config_file {
496 Ok(path.clone())
497 } else {
498 Self::default_config_file_path()
499 }
500 }
501
502 fn get_themes_path() -> Result<PathBuf, SettingsError> {
503 Ok(system::ensure_app_dir()?.join(THEMES_DIR).into_path_buf())
504 }
505
506 fn load_theme_from_path(path: &std::path::Path) -> Result<Theme, SettingsError> {
507 let theme_string = fs::read_to_string(path)?;
508 match toml::from_str::<Theme>(&theme_string) {
509 Ok(theme) => Ok(theme),
510 Err(e) => {
511 tracing::warn!("Skipping unparsable theme file {:?}: {}", path, e);
514 Err(SettingsError::CorruptTheme(e))
515 }
516 }
517 }
518
519 fn load_default_theme() -> Result<Theme, SettingsError> {
520 let theme_path = AppSettings::get_themes_path()?.join("default.toml");
521 Self::load_theme_from_path(&theme_path)
522 }
523
524 fn load_custom_themes() -> Vec<Theme> {
525 let mut themes = Vec::new();
526
527 let themes_path = match Self::get_themes_path() {
529 Ok(path) => path,
530 Err(_) => return themes,
531 };
532
533 let entries =
535 match SystemPath::try_absolute(&themes_path).and_then(|dir| system::read_dir(&dir)) {
536 Ok(entries) => entries,
537 Err(_) => return themes,
538 };
539
540 for entry in entries {
542 let path = entry.into_path_buf();
543
544 if !path.is_file() {
546 continue;
547 }
548
549 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
551 continue;
552 }
553
554 if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
556 continue;
557 }
558
559 match fs::read_to_string(&path)
561 .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
562 {
563 Ok(theme) => themes.push(theme),
564 Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
565 }
566 }
567
568 themes
569 }
570
571 pub fn update_check(&self) -> bool {
575 self.workspace_config
576 .as_ref()
577 .map(|wc| wc.global.update_check)
578 .unwrap_or(true)
579 }
580
581 pub fn mouse(&self) -> bool {
584 self.workspace_config
585 .as_ref()
586 .map(|wc| wc.global.mouse)
587 .unwrap_or(true)
588 }
589
590 pub fn save_to_disk(&self) -> Result<(), SettingsError> {
591 tracing::debug!("Saving settings to disk");
592 let settings_file_path = self.get_config_file_path()?;
593 let body = format!("{CONFIG_HEADER}{}", toml::to_string(&self)?);
597 system::replace_atomically(&settings_file_path, body.as_bytes())?;
598 Ok(())
599 }
600
601 pub fn load_from_disk() -> Result<Self, SettingsError> {
602 let settings_file_path = Self::default_config_file_path()?;
603
604 if !settings_file_path.exists() {
605 let default_settings = Self::defaults_for_config_file(settings_file_path);
606 default_settings.save_to_disk()?;
607 Ok(default_settings)
608 } else {
609 let mut settings_file = File::open(&settings_file_path)?;
610
611 let mut toml = String::new();
612 settings_file.read_to_string(&mut toml)?;
613
614 match toml::from_str::<AppSettings>(toml.as_ref()) {
615 Ok(mut setting) => {
616 setting.config_file = Some(settings_file_path.clone());
617 setting.resolve_paths(&Self::config_base_dir(&settings_file_path));
620 if config_migration::ConfigMigration::run(&mut setting)? {
621 setting.save_to_disk()?;
622 }
623 setting.merge_missing_default_bindings();
624 Ok(setting)
625 }
626 Err(e) => {
627 tracing::warn!(
628 "Config file at {:?} could not be parsed ({}). \
629 Renaming to .corrupt and starting with defaults.",
630 settings_file_path,
631 e
632 );
633 let corrupt_path = settings_file_path.with_extension("toml.corrupt");
634 let _ = system::move_file(&settings_file_path, &corrupt_path);
635 let defaults = Self::defaults_for_config_file(settings_file_path);
636 defaults.save_to_disk()?;
637 Ok(defaults)
638 }
639 }
640 }
641 }
642
643 pub fn load_from_file(path: PathBuf) -> Result<Self, SettingsError> {
644 if let Some(parent) = path.parent()
649 && !parent.as_os_str().is_empty()
650 {
651 system::create_dir(parent)?;
652 }
653 if !path.exists() {
654 let default_settings = Self::defaults_for_config_file(path);
655 default_settings.save_to_disk()?;
656 return Ok(default_settings);
657 }
658 let mut toml_str = String::new();
659 File::open(&path)?.read_to_string(&mut toml_str)?;
660 match toml::from_str::<AppSettings>(&toml_str) {
661 Ok(mut setting) => {
662 setting.config_file = Some(path.clone());
663
664 setting.resolve_paths(&Self::config_base_dir(&path));
667
668 if config_migration::ConfigMigration::run(&mut setting)? {
670 setting.save_to_disk()?;
671 }
672
673 setting.merge_missing_default_bindings();
674 Ok(setting)
675 }
676 Err(e) => {
677 tracing::warn!(
678 "Config file at {:?} could not be parsed ({}). \
679 Renaming to .corrupt and starting with defaults.",
680 path,
681 e
682 );
683 let corrupt_path = path.with_extension("toml.corrupt");
684 let _ = system::move_file(&path, &corrupt_path);
685 let defaults = Self::defaults_for_config_file(path);
686 defaults.save_to_disk()?;
687 Ok(defaults)
688 }
689 }
690 }
691
692 fn merge_missing_default_bindings(&mut self) {
698 let defaults = default_keybindings().to_hashmap();
699 let mut current = self.key_bindings.to_hashmap();
700 let mut bound: std::collections::HashSet<_> = current.values().flatten().cloned().collect();
701 for (action, combos) in defaults {
702 match current.entry(action) {
703 std::collections::hash_map::Entry::Vacant(e) => {
704 let free: Vec<_> = combos.into_iter().filter(|c| !bound.contains(c)).collect();
708 if !free.is_empty() {
709 bound.extend(free.iter().copied());
710 e.insert(free);
711 }
712 }
713 std::collections::hash_map::Entry::Occupied(mut e) => {
714 for combo in combos {
715 if !bound.contains(&combo) && !e.get().contains(&combo) {
716 bound.insert(combo);
717 e.get_mut().push(combo);
718 }
719 }
720 }
721 }
722 }
723 self.key_bindings = KeyBindings::from_hashmap(current);
724 }
725
726 pub fn set_workspace_path(&mut self, name: &str, workspace_path: PathBuf) {
731 let Some(entry) = self
732 .workspace_config
733 .as_mut()
734 .and_then(|wc| wc.workspaces.get_mut(name))
735 else {
736 return;
737 };
738 if *entry.effective_path() != workspace_path {
739 self.needs_indexing = true;
740 }
741 entry.path = workspace_path;
742 entry.resolved_path = None;
743 }
744
745 pub fn clear_workspace(&mut self) {
758 if let Some(wc) = &mut self.workspace_config {
759 let key = wc.global.current_workspace.clone();
760 if !key.is_empty() {
761 wc.workspaces.remove(&key);
762 }
763 wc.global.current_workspace = String::new();
764 }
765 }
766
767 pub fn resolve_workspace_path(&self) -> Option<SystemPath> {
770 let raw = self
771 .workspace_config
772 .as_ref()
773 .and_then(|wc| wc.get_current_workspace())
774 .map(|entry| entry.effective_path().clone())?;
775 match SystemPath::try_absolute(&raw) {
780 Ok(path) => Some(path),
781 Err(e) => {
782 tracing::warn!("ignoring unusable workspace path {raw:?}: {e}");
783 None
784 }
785 }
786 }
787
788 fn resolve_paths(&mut self, base: &SystemPath) {
792 if let Some(ref mut wc) = self.workspace_config {
794 for entry in wc.workspaces.values_mut() {
795 let resolved = SystemPath::resolve(&entry.path, base).into_path_buf();
796 if resolved != entry.path {
797 entry.resolved_path = Some(resolved);
798 }
799 }
800 }
801 self.cache_dir_resolved = SystemPath::resolve(&self.cache_dir, base);
802 self.history_dir_resolved = SystemPath::resolve(&self.history_dir, base);
803 }
804
805 fn defaults_for_config_file(path: PathBuf) -> Self {
816 let base = Self::config_base_dir(&path);
817 let mut settings = Self {
818 config_file: Some(path),
819 ..Self::default()
820 };
821 settings.resolve_paths(&base);
822 settings
823 }
824
825 fn config_base_dir(config_file: &std::path::Path) -> SystemPath {
843 let dir = config_file
844 .parent()
845 .filter(|p| !p.as_os_str().is_empty())
846 .unwrap_or(std::path::Path::new("."));
847 SystemPath::canonical(dir)
851 .or_else(|_| {
852 let cwd = std::env::current_dir().unwrap_or_default();
853 SystemPath::try_absolute(cwd.join(dir))
854 })
855 .unwrap_or_else(|_| default_settings_base_dir())
856 }
857
858 pub fn set_theme(&mut self, theme: String) {
859 self.theme = theme;
860 }
861
862 pub fn report_indexed(&mut self) {
863 self.needs_indexing = false;
864 }
865
866 pub fn needs_indexing(&self) -> bool {
867 self.needs_indexing
868 }
869
870 pub fn add_path_history(&mut self, note_path: &VaultPath) {
871 if !note_path.is_note() {
872 return;
873 }
874 let Some(workspace_name) = self.current_workspace_name() else {
875 return;
876 };
877 let history = self.history_for(&workspace_name);
878 if let Err(e) = history.push(note_path) {
879 tracing::warn!("failed to write history {history}: {e}");
880 }
881 }
882
883 pub fn current_workspace_name(&self) -> Option<String> {
884 self.workspace_config
885 .as_ref()
886 .map(|wc| wc.global.current_workspace.clone())
887 .filter(|s| !s.is_empty())
888 }
889
890 pub fn cache_dir_resolved(&self) -> &SystemPath {
892 &self.cache_dir_resolved
893 }
894
895 pub fn history_dir_resolved(&self) -> &SystemPath {
897 &self.history_dir_resolved
898 }
899
900 fn file_key_for(&self, workspace_name: &str) -> String {
909 self.workspace_config
910 .as_ref()
911 .and_then(|wc| wc.get_workspace(workspace_name))
912 .map(|entry| entry.file_key_or(workspace_name))
913 .unwrap_or_else(|| workspace_name.to_string())
914 }
915
916 pub fn index_for(&self, workspace_name: &str) -> IndexFile {
923 IndexFile::in_dir(&self.cache_dir_resolved, &self.file_key_for(workspace_name))
924 }
925
926 pub fn history_for(&self, workspace_name: &str) -> HistoryFile {
930 HistoryFile::in_dir(
931 &self.history_dir_resolved,
932 &self.file_key_for(workspace_name),
933 )
934 }
935
936 pub fn workspace_artifacts(&self, workspace_name: &str) -> (IndexFile, HistoryFile) {
946 (
947 self.index_for(workspace_name),
948 self.history_for(workspace_name),
949 )
950 }
951
952 pub fn current_last_paths(&self) -> Vec<VaultPath> {
954 let Some(name) = self.current_workspace_name() else {
955 return Vec::new();
956 };
957 self.history_for(&name).load()
958 }
959
960 pub fn icons(&self) -> icons::Icons {
962 icons::Icons::new(self.use_nerd_fonts)
963 }
964
965 pub fn yank_combos(&self) -> Vec<crate::keys::key_combo::KeyCombo> {
969 self.key_bindings.combos_for(&ActionShortcuts::YankRow)
970 }
971
972 pub fn effective_theme_name(&self) -> String {
976 if self.theme.is_empty() {
977 Theme::default().name
978 } else {
979 self.theme.clone()
980 }
981 }
982
983 pub fn get_theme(&self) -> Theme {
989 let theme = if self.theme.is_empty() {
990 Theme::default()
991 } else {
992 self.theme_list()
993 .into_iter()
994 .find(|t| t.name == self.theme)
995 .unwrap_or_default()
996 };
997 theme.adapt_to_terminal()
998 }
999}
1000
1001#[cfg(test)]
1003mod test_paths {
1004 use std::path::PathBuf;
1005
1006 pub(super) fn absolute(unix_style: &str) -> PathBuf {
1018 let trimmed = unix_style.trim_start_matches('/');
1019 if cfg!(windows) {
1020 PathBuf::from(format!("C:\\{}", trimmed.replace('/', "\\")))
1021 } else {
1022 PathBuf::from(format!("/{trimmed}"))
1023 }
1024 }
1025
1026 pub(super) fn absolute_toml(unix_style: &str) -> String {
1029 absolute(unix_style).to_string_lossy().replace('\\', "\\\\")
1030 }
1031}
1032
1033#[cfg(test)]
1034#[allow(clippy::field_reassign_with_default)]
1035mod tests {
1036 use super::test_paths::absolute;
1037 use super::*;
1038
1039 #[test]
1040 fn default_workspace_suggestion_is_under_home() {
1041 let suggestion = AppSettings::default_workspace_suggestion();
1042 if let Some(p) = suggestion {
1043 assert!(p.ends_with("kimun-notes"));
1044 assert!(p.is_absolute());
1045 }
1046 }
1048
1049 #[test]
1050 fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
1051 let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
1054 let _ = std::fs::remove_file(&path); let result = AppSettings::load_theme_from_path(&path);
1057
1058 assert!(result.is_err(), "should return Err when file is absent");
1059 assert!(!path.exists(), "must not create the file as a side effect");
1060 }
1061
1062 #[test]
1063 fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
1064 let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
1066 std::fs::write(&path, b"not valid toml {{{{").unwrap();
1067
1068 let result = AppSettings::load_theme_from_path(&path);
1069
1070 assert!(result.is_err(), "should return Err for corrupt TOML");
1071 assert!(path.exists(), "corrupt theme file must not be deleted");
1074 std::fs::remove_file(&path).ok();
1075 }
1076
1077 #[test]
1078 fn default_keybindings_quit_matches_canonical_combo() {
1079 let kb = default_keybindings();
1080 let combo = crate::keys::default_quit_combo();
1081 assert_eq!(
1082 kb.get_action(&combo),
1083 Some(ActionShortcuts::Quit),
1084 "default_keybindings() must bind default_quit_combo() to Quit so the \
1085 deserialize safety net can recover an unreachable app"
1086 );
1087 }
1088
1089 #[test]
1090 fn autosave_interval_defaults_to_five() {
1091 let settings = AppSettings::default();
1092 assert_eq!(settings.autosave_interval_secs, 5);
1093 }
1094
1095 #[test]
1096 fn autosave_interval_deserializes_from_toml() {
1097 let toml = "autosave_interval_secs = 30\n";
1098 let settings: AppSettings = toml::from_str(toml).unwrap();
1099 assert_eq!(settings.autosave_interval_secs, 30);
1100 }
1101
1102 #[test]
1103 fn autosave_interval_defaults_when_missing_from_toml() {
1104 let toml = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
1106 assert_eq!(settings.autosave_interval_secs, 5);
1107 }
1108
1109 #[test]
1111 fn f2_file_operations_survives_toml_deserialize() {
1112 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1113 use crate::keys::key_strike::KeyStrike;
1114
1115 let toml = r#"
1116[key_bindings]
1117FileOperations = ["F2"]
1118"#;
1119 let settings: AppSettings = toml::from_str(toml).unwrap();
1120 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1121 let action = settings.key_bindings.get_action(&f2);
1122 assert_eq!(
1123 action,
1124 Some(ActionShortcuts::FileOperations),
1125 "F2 should survive deserialization and map to FileOperations"
1126 );
1127 }
1128
1129 #[test]
1131 fn merge_adds_f2_when_absent() {
1132 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1133 use crate::keys::key_strike::KeyStrike;
1134
1135 let toml = r#"
1137[key_bindings]
1138Quit = ["ctrl&Q"]
1139"#;
1140 let mut settings: AppSettings = toml::from_str(toml).unwrap();
1141 settings.merge_missing_default_bindings();
1142
1143 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1144 let action = settings.key_bindings.get_action(&f2);
1145 assert_eq!(
1146 action,
1147 Some(ActionShortcuts::FileOperations),
1148 "merge_missing_default_bindings should add F2 → FileOperations"
1149 );
1150 }
1151
1152 #[test]
1153 fn clear_workspace_removes_the_current_workspace_entry() {
1154 let mut settings = AppSettings::default();
1155 let mut wc = WorkspaceConfig::new_empty();
1156 wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1157 .unwrap();
1158 settings.workspace_config = Some(wc);
1159 assert_eq!(
1161 settings
1162 .workspace_config
1163 .as_ref()
1164 .unwrap()
1165 .global
1166 .current_workspace,
1167 "vault1"
1168 );
1169 settings.clear_workspace();
1170 let wc = settings.workspace_config.as_ref().unwrap();
1171 assert!(
1172 wc.workspaces.is_empty(),
1173 "workspace entry should be removed"
1174 );
1175 assert!(
1176 wc.global.current_workspace.is_empty(),
1177 "current_workspace should be empty"
1178 );
1179 }
1180
1181 #[test]
1182 fn clear_workspace_preserves_other_workspaces() {
1183 let mut settings = AppSettings::default();
1184 let mut wc = WorkspaceConfig::new_empty();
1185 wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1186 .unwrap();
1187 wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
1188 .unwrap();
1189 wc.global.current_workspace = "vault1".to_string();
1190 settings.workspace_config = Some(wc);
1191 settings.clear_workspace();
1192 let wc = settings.workspace_config.as_ref().unwrap();
1193 assert!(
1194 !wc.workspaces.contains_key("vault1"),
1195 "active workspace should be removed"
1196 );
1197 assert!(
1198 wc.workspaces.contains_key("vault2"),
1199 "other workspaces should be preserved"
1200 );
1201 assert!(
1202 wc.global.current_workspace.is_empty(),
1203 "current_workspace should be empty"
1204 );
1205 }
1206}
1207
1208#[cfg(test)]
1209mod backend_tests {
1210 use super::test_paths::{absolute, absolute_toml};
1211 use super::*;
1212
1213 #[test]
1214 fn a_config_still_saying_textarea_loads() {
1215 #[derive(serde::Deserialize)]
1219 struct Holder {
1220 editor_backend: EditorBackendSetting,
1221 }
1222 let old: Holder = toml::from_str("editor_backend = \"textarea\"").expect("still loads");
1223 assert_eq!(old.editor_backend, EditorBackendSetting::Plain);
1224 }
1225
1226 #[test]
1227 fn the_backend_is_written_back_as_plain() {
1228 let written = toml::to_string(&AppSettings::default()).expect("serialises");
1229 assert!(
1230 written.contains("editor_backend = \"plain\""),
1231 "a saved config should name the value as it is now: {written}"
1232 );
1233 }
1234
1235 #[test]
1236 fn default_backend_is_plain() {
1237 let settings = AppSettings::default();
1238 assert!(matches!(
1239 settings.editor_backend,
1240 EditorBackendSetting::Plain
1241 ));
1242 }
1243
1244 #[test]
1245 fn nvim_backend_round_trips_toml() {
1246 let toml = "editor_backend = \"nvim\"\n";
1247 let parsed: AppSettings = toml::from_str(toml).unwrap();
1248 assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
1249 }
1250
1251 #[test]
1252 fn editor_backend_vim_roundtrips_through_toml() {
1253 #[derive(serde::Serialize, serde::Deserialize)]
1254 struct W {
1255 editor_backend: EditorBackendSetting,
1256 }
1257 let w = W {
1258 editor_backend: EditorBackendSetting::Vim,
1259 };
1260 let s = toml::to_string(&w).unwrap();
1261 assert!(s.contains("editor_backend = \"vim\""), "serialized: {s}");
1262 let back: W = toml::from_str(&s).unwrap();
1263 assert_eq!(back.editor_backend, EditorBackendSetting::Vim);
1264 }
1265
1266 #[test]
1272 fn workspace_paths_are_absolute_without_resolve_paths() {
1273 for settings in [
1274 AppSettings::default(),
1275 toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n").unwrap(),
1276 ] {
1277 let cache = settings.index_for("w");
1278 let history = settings.history_for("w");
1279 assert!(
1280 cache.path().as_path().is_absolute(),
1281 "cache path not absolute: {cache}"
1282 );
1283 assert!(
1284 history.path().as_path().is_absolute(),
1285 "history path not absolute: {history}"
1286 );
1287 }
1288 }
1289
1290 #[test]
1294 fn resolve_paths_populates_resolved_path() {
1295 let base = tempfile::TempDir::new().unwrap();
1296 let notes = base.path().join("notes");
1297 std::fs::create_dir_all(¬es).unwrap();
1298
1299 let toml = r#"
1300config_version = 2
1301[global]
1302current_workspace = "test"
1303[workspaces.test]
1304path = "notes"
1305last_paths = []
1306created = "2026-01-01T00:00:00Z"
1307"#
1308 .to_string();
1309 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1310 settings.resolve_paths(&SystemPath::try_absolute(base.path()).unwrap());
1311
1312 let wc = settings.workspace_config.as_ref().unwrap();
1313 let entry = wc.workspaces.get("test").unwrap();
1314 assert_eq!(entry.path, PathBuf::from("notes"));
1316 assert!(entry.resolved_path.is_some());
1318 assert!(entry.effective_path().is_absolute());
1319 }
1320
1321 #[test]
1331 fn load_from_file_resolves_paths_for_a_config_that_does_not_exist_yet() {
1332 let dir = tempfile::TempDir::new().unwrap();
1333 let config_dir = dir.path().canonicalize().unwrap();
1334 let settings = AppSettings::load_from_file(config_dir.join("config.toml")).unwrap();
1335
1336 let cache = settings.index_for("work");
1337 let history = settings.history_for("work");
1338 assert!(
1339 cache.path().as_path().starts_with(&config_dir),
1340 "cache path must sit next to the config file, got {cache}"
1341 );
1342 assert!(
1343 history.path().as_path().starts_with(&config_dir),
1344 "history path must sit next to the config file, got {history}"
1345 );
1346 }
1347
1348 #[test]
1352 fn load_from_file_resolves_paths_when_the_config_is_corrupt() {
1353 let dir = tempfile::TempDir::new().unwrap();
1354 let config_dir = dir.path().canonicalize().unwrap();
1355 let config_path = config_dir.join("config.toml");
1356 std::fs::write(&config_path, "not = valid toml [[[").unwrap();
1357
1358 let settings = AppSettings::load_from_file(config_path).unwrap();
1359
1360 let cache = settings.index_for("work");
1361 assert!(
1362 cache.path().as_path().starts_with(&config_dir),
1363 "cache path must sit next to the config file, got {cache}"
1364 );
1365 }
1366
1367 #[test]
1368 fn resolve_paths_absolute_no_resolved_path() {
1369 let toml = format!(
1373 r#"
1374config_version = 2
1375[global]
1376current_workspace = "test"
1377[workspaces.test]
1378path = "{}"
1379last_paths = []
1380created = "2026-01-01T00:00:00Z"
1381"#,
1382 absolute_toml("/absolute/notes")
1383 );
1384 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1385 settings.resolve_paths(&SystemPath::try_absolute(absolute("/config")).unwrap());
1386
1387 let wc = settings.workspace_config.as_ref().unwrap();
1388 let entry = wc.workspaces.get("test").unwrap();
1389 assert!(entry.resolved_path.is_none());
1391 assert_eq!(*entry.effective_path(), absolute("/absolute/notes"));
1392 }
1393}
1394
1395#[cfg(test)]
1396mod sort_settings_tests {
1397 use super::*;
1398
1399 #[test]
1400 fn group_directories_defaults_off() {
1401 let s = AppSettings::default();
1402 assert!(!s.group_directories);
1403 }
1404
1405 #[test]
1406 fn open_sort_dialog_is_bound_by_default() {
1407 let s = AppSettings::default();
1408 let map = s.key_bindings.to_hashmap();
1409 assert!(
1410 map.contains_key(&ActionShortcuts::OpenSortDialog),
1411 "OpenSortDialog must have a default binding"
1412 );
1413 }
1414}