1use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
2use crate::keys::key_strike::KeyStrike;
3use crate::settings::config_dir::get_or_create_config_dir;
4use crate::settings::themes::Theme;
5use crate::settings::workspace_config::WorkspaceConfig;
6use std::io::{Read, Write};
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, RwLock};
9
10use std::fs::{self, File};
11
12#[derive(Debug, thiserror::Error)]
16pub enum SettingsError {
17 #[error(transparent)]
18 Io(#[from] std::io::Error),
19 #[error("cannot serialize settings: {0}")]
20 Serialize(#[from] toml::ser::Error),
21 #[error("corrupt theme file: {0}")]
22 CorruptTheme(toml::de::Error),
23 #[error("config migration failed: {0}")]
24 Migration(String),
25}
26
27pub type SharedSettings = Arc<RwLock<AppSettings>>;
29use kimun_core::nfs::VaultPath;
30
31use crate::keys::KeyBindings;
32mod config_dir;
33pub(crate) use config_dir::get_home_dir;
34pub mod config_migration;
35pub mod history;
36pub mod icons;
37pub mod themes;
38pub mod workspace_config;
39
40#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum SortFieldSetting {
47 Name,
48 Title,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum SortOrderSetting {
54 Ascending,
55 Descending,
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum EditorBackendSetting {
61 #[default]
62 Textarea,
63 Nvim,
64 Vim,
65}
66
67#[cfg(debug_assertions)]
70const CONFIG_DIR: &str = "kimun_debug";
71#[cfg(not(debug_assertions))]
72const CONFIG_DIR: &str = "kimun";
73
74pub fn config_dir() -> std::io::Result<PathBuf> {
79 get_or_create_config_dir(CONFIG_DIR)
80}
81
82const BASE_CONFIG_FILE: &str = "config.toml";
83const THEMES_DIR: &str = "themes";
84const CACHE_FILE_EXT: &str = "kimuncache";
85const HISTORY_FILE_EXT: &str = "txt";
86
87const CONFIG_HEADER: &str = "\
88# ─── Kimün configuration ────────────────────────────────────────────────────
89#
90# KEY BINDINGS
91# ────────────
92# Supported combinations:
93# - ctrl and/or alt (with optional shift) + a letter (a-z)
94# - bare F-key (F1–F12, no modifier required)
95# Any combo that does not follow these rules is silently ignored when loaded.
96#
97# Format per action:
98# ActionName = [\"<modifiers> & <letter>\", ...]
99#
100# Available modifiers (combine with +): ctrl alt shift
101#
102# Examples:
103# Quit = [\"ctrl&Q\"] # Ctrl+Q
104# SearchNotes = [\"ctrl&K\"] # Ctrl+K
105# OpenNote = [\"ctrl&O\"] # Ctrl+O (fuzzy file finder)
106# OpenSettings = [\"F4\", \"ctrl&,\"] # F4 (Ctrl+, alias)
107# NewJournal = [\"ctrl&J\"] # Ctrl+J
108# FileOperations = [\"F2\"] # F2 (open file-ops menu: delete/rename/move)
109# Leader = [\"ctrl&G\"] # Ctrl+G (leader gateway: Ctrl+G f f, ...)
110# OpenCommandPalette = [\"ctrl&P\"] # Ctrl+P (every leader command, fuzzy)
111#
112# OTHER SETTINGS
113# ──────────────
114# theme = \"Gruvbox Dark\" # or any built-in / custom theme name
115# leader_timeout_ms = 400 # hesitation before the which-key menu
116#
117# LEADER TREE OVERRIDES
118# ─────────────────────
119# Remap, add, or remove leader sequences ([leader.bind]) and rename group
120# captions ([leader.labels]). Keys are the sequence AFTER the gateway;
121# bind values are action ids (see the cheatsheet) or \"none\" to unbind.
122# [leader.bind]
123# \"o f\" = \"find.files\" # remap: leader o f now opens the file picker
124# \"x\" = \"note.daily\" # add: leader x opens today's journal
125# \"g p\" = \"none\" # remove the git-sync stub binding
126# [leader.labels]
127# \"f\" = \"+search\" # rename the +find group caption
128#
129# ─────────────────────────────────────────────────────────────────────────────
130";
131
132#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
133pub struct AppSettings {
134 #[serde(default)]
136 pub config_version: u32,
137 #[serde(flatten, skip_serializing_if = "Option::is_none")]
138 pub workspace_config: Option<WorkspaceConfig>,
139
140 #[serde(skip_serializing_if = "Option::is_none")]
144 pub workspace_dir: Option<PathBuf>,
145 #[serde(default, skip_serializing)]
146 pub last_paths: Vec<VaultPath>,
147
148 #[serde(default)]
150 pub theme: String,
151 #[serde(default = "default_cache_dir")]
152 pub cache_dir: PathBuf,
153 #[serde(skip)]
154 cache_dir_resolved: Option<PathBuf>,
155
156 #[serde(default = "default_history_dir")]
157 pub history_dir: PathBuf,
158 #[serde(skip)]
159 history_dir_resolved: Option<PathBuf>,
160 #[serde(skip, default = "yes")]
161 needs_indexing: bool,
162 #[serde(default = "default_keybindings")]
163 pub key_bindings: KeyBindings,
164 #[serde(default = "default_autosave_interval")]
165 pub autosave_interval_secs: u64,
166 #[serde(default = "default_leader_timeout_ms")]
169 pub leader_timeout_ms: u64,
170 #[serde(default)]
174 pub leader: LeaderConfig,
175 #[serde(default = "default_use_nerd_fonts")]
176 pub use_nerd_fonts: bool,
177 #[serde(default)]
178 pub editor_backend: EditorBackendSetting,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub nvim_path: Option<std::path::PathBuf>,
181 #[serde(default = "default_sort_field")]
182 pub default_sort_field: SortFieldSetting,
183 #[serde(default = "default_sort_order")]
184 pub default_sort_order: SortOrderSetting,
185 #[serde(default = "default_journal_sort_field")]
186 pub journal_sort_field: SortFieldSetting,
187 #[serde(default = "default_journal_sort_order")]
188 pub journal_sort_order: SortOrderSetting,
189 #[serde(default)]
190 pub group_directories: bool,
191 #[serde(skip)]
194 pub config_file: Option<PathBuf>,
195}
196
197fn default_keybindings() -> KeyBindings {
198 let mut kb = KeyBindings::empty();
199 kb.batch_add()
200 .with_ctrl()
201 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
202 .add(KeyStrike::KeyO, ActionShortcuts::OpenNote)
203 .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
204 .add(KeyStrike::KeyI, ActionShortcuts::Text(TextAction::Italic))
205 .add(
206 KeyStrike::KeyU,
207 ActionShortcuts::Text(TextAction::Underline),
208 )
209 .add(
210 KeyStrike::KeyS,
211 ActionShortcuts::Text(TextAction::Strikethrough),
212 )
213 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Link))
214 .add(
215 KeyStrike::KeyT,
216 ActionShortcuts::Text(TextAction::ToggleHeader),
217 )
218 .with_shift()
222 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
223
224 kb.batch_add()
228 .with_ctrl()
229 .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
232 .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
233 .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
234 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
238 .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
239 .add(KeyStrike::KeyG, ActionShortcuts::Leader)
242 .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
245 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
246 .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
247 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
248 .add(KeyStrike::KeyE, ActionShortcuts::OpenFileBrowser)
252 .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer);
253
254 kb.batch_add()
260 .add(KeyStrike::F4, ActionShortcuts::OpenPreferences);
261 kb.batch_add()
262 .with_ctrl()
263 .add(KeyStrike::Comma, ActionShortcuts::OpenPreferences);
264
265 kb.batch_add()
267 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
268
269 kb.batch_add()
270 .add(KeyStrike::F3, ActionShortcuts::OpenSavedSearches);
271
272 kb.batch_add().add(KeyStrike::F6, ActionShortcuts::OpenAsk);
274
275 kb.batch_add()
277 .add(KeyStrike::F5, ActionShortcuts::SwitchWorkspace);
278
279 kb.batch_add()
284 .with_ctrl()
285 .add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
286
287 kb
288}
289
290fn yes() -> bool {
291 true
292}
293
294fn default_autosave_interval() -> u64 {
295 5
296}
297
298fn default_leader_timeout_ms() -> u64 {
299 400
300}
301
302#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
304pub struct LeaderConfig {
305 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
308 pub bind: std::collections::BTreeMap<String, String>,
309 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
312 pub labels: std::collections::BTreeMap<String, String>,
313}
314
315impl AppSettings {
316 pub fn default_workspace_suggestion() -> Option<PathBuf> {
319 config_dir::get_home_dir()
320 .ok()
321 .map(|h| h.join("kimun-notes"))
322 }
323
324 pub fn leader_tree(&self) -> crate::keys::leader::LeaderNode {
328 let tree = crate::keys::leader::apply_overrides(
329 crate::keys::leader::leader_tree(),
330 self.leader
331 .bind
332 .iter()
333 .map(|(k, v)| (k.as_str(), v.as_str())),
334 );
335 crate::keys::leader::apply_labels(
336 tree,
337 self.leader
338 .labels
339 .iter()
340 .map(|(k, v)| (k.as_str(), v.as_str())),
341 )
342 }
343}
344
345fn default_cache_dir() -> PathBuf {
346 PathBuf::from(".")
347}
348
349fn default_history_dir() -> PathBuf {
350 PathBuf::from("history")
351}
352
353fn default_use_nerd_fonts() -> bool {
354 false
355}
356
357fn default_sort_field() -> SortFieldSetting {
358 SortFieldSetting::Name
359}
360
361fn default_sort_order() -> SortOrderSetting {
362 SortOrderSetting::Ascending
363}
364
365fn default_journal_sort_field() -> SortFieldSetting {
366 SortFieldSetting::Name
367}
368
369fn default_journal_sort_order() -> SortOrderSetting {
370 SortOrderSetting::Descending
371}
372
373impl Default for AppSettings {
374 fn default() -> Self {
375 Self {
376 config_version: 0,
377 workspace_config: None,
378 last_paths: vec![],
379 workspace_dir: None,
380 theme: Default::default(),
381 cache_dir: default_cache_dir(),
382 cache_dir_resolved: None,
383 history_dir: default_history_dir(),
384 history_dir_resolved: None,
385 needs_indexing: true,
386 key_bindings: default_keybindings(),
387 autosave_interval_secs: default_autosave_interval(),
388 leader_timeout_ms: default_leader_timeout_ms(),
389 leader: LeaderConfig::default(),
390 use_nerd_fonts: false,
391 editor_backend: EditorBackendSetting::Textarea,
392 nvim_path: None,
393 default_sort_field: default_sort_field(),
394 default_sort_order: default_sort_order(),
395 journal_sort_field: default_journal_sort_field(),
396 journal_sort_order: default_journal_sort_order(),
397 group_directories: false,
398 config_file: None,
399 }
400 }
401}
402
403impl AppSettings {
404 pub fn theme_list(&self) -> Vec<Theme> {
405 let mut list = Theme::builtins();
406 list.append(&mut Self::load_custom_themes());
407 if let Ok(custom_default) = Self::load_default_theme() {
409 list.push(custom_default);
410 }
411 list.sort_by(|a, b| a.name.cmp(&b.name));
412 list
413 }
414
415 fn default_config_file_path() -> Result<PathBuf, SettingsError> {
416 let config_home = get_or_create_config_dir(CONFIG_DIR)?;
417 Ok(config_home.join(BASE_CONFIG_FILE))
418 }
419
420 fn get_config_file_path(&self) -> Result<PathBuf, SettingsError> {
421 if let Some(ref path) = self.config_file {
422 Ok(path.clone())
423 } else {
424 Self::default_config_file_path()
425 }
426 }
427
428 fn get_themes_path() -> Result<PathBuf, SettingsError> {
429 let config_home = get_or_create_config_dir(CONFIG_DIR)?;
430 Ok(config_home.join(THEMES_DIR))
431 }
432
433 fn load_theme_from_path(path: &std::path::Path) -> Result<Theme, SettingsError> {
434 let theme_string = fs::read_to_string(path)?;
435 match toml::from_str::<Theme>(&theme_string) {
436 Ok(theme) => Ok(theme),
437 Err(e) => {
438 tracing::warn!("Skipping unparsable theme file {:?}: {}", path, e);
441 Err(SettingsError::CorruptTheme(e))
442 }
443 }
444 }
445
446 fn load_default_theme() -> Result<Theme, SettingsError> {
447 let theme_path = AppSettings::get_themes_path()?.join("default.toml");
448 Self::load_theme_from_path(&theme_path)
449 }
450
451 fn load_custom_themes() -> Vec<Theme> {
452 let mut themes = Vec::new();
453
454 let themes_path = match Self::get_themes_path() {
456 Ok(path) => path,
457 Err(_) => return themes,
458 };
459
460 let entries = match fs::read_dir(&themes_path) {
462 Ok(entries) => entries,
463 Err(_) => return themes,
464 };
465
466 for entry in entries.flatten() {
468 let path = entry.path();
469
470 if !path.is_file() {
472 continue;
473 }
474
475 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
477 continue;
478 }
479
480 if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
482 continue;
483 }
484
485 match fs::read_to_string(&path)
487 .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
488 {
489 Ok(theme) => themes.push(theme),
490 Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
491 }
492 }
493
494 themes
495 }
496
497 pub fn update_check(&self) -> bool {
501 self.workspace_config
502 .as_ref()
503 .map(|wc| wc.global.update_check)
504 .unwrap_or(true)
505 }
506
507 pub fn mouse(&self) -> bool {
510 self.workspace_config
511 .as_ref()
512 .map(|wc| wc.global.mouse)
513 .unwrap_or(true)
514 }
515
516 pub fn save_to_disk(&self) -> Result<(), SettingsError> {
517 tracing::debug!("Saving settings to disk");
518 let settings_file_path = self.get_config_file_path()?;
519 let mut file = File::create(settings_file_path)?;
520 file.write_all(CONFIG_HEADER.as_bytes())?;
521 let toml = toml::to_string(&self)?;
522 file.write_all(toml.as_bytes())?;
523 Ok(())
524 }
525
526 pub fn load_from_disk() -> Result<Self, SettingsError> {
527 let settings_file_path = Self::default_config_file_path()?;
528
529 if !settings_file_path.exists() {
530 let default_settings = Self::default();
531 default_settings.save_to_disk()?;
532 Ok(default_settings)
533 } else {
534 let mut settings_file = File::open(&settings_file_path)?;
535
536 let mut toml = String::new();
537 settings_file.read_to_string(&mut toml)?;
538
539 match toml::from_str::<AppSettings>(toml.as_ref()) {
540 Ok(mut setting) => {
541 setting.config_file = Some(settings_file_path.clone());
542 let config_dir = settings_file_path
543 .parent()
544 .unwrap_or(std::path::Path::new("."));
545 setting.resolve_paths(config_dir);
546 if config_migration::ConfigMigration::run(&mut setting)? {
547 setting.save_to_disk()?;
548 }
549 setting.merge_missing_default_bindings();
550 Ok(setting)
551 }
552 Err(e) => {
553 tracing::warn!(
554 "Config file at {:?} could not be parsed ({}). \
555 Renaming to .corrupt and starting with defaults.",
556 settings_file_path,
557 e
558 );
559 let corrupt_path = settings_file_path.with_extension("toml.corrupt");
560 let _ = fs::rename(&settings_file_path, &corrupt_path);
561 let defaults = Self::default();
562 defaults.save_to_disk()?;
563 Ok(defaults)
564 }
565 }
566 }
567 }
568
569 pub fn load_from_file(path: PathBuf) -> Result<Self, SettingsError> {
570 if let Some(parent) = path.parent() {
571 fs::create_dir_all(parent)?;
572 }
573 if !path.exists() {
574 let default_settings = Self {
575 config_file: Some(path),
576 ..Self::default()
577 };
578 default_settings.save_to_disk()?;
579 return Ok(default_settings);
580 }
581 let mut toml_str = String::new();
582 File::open(&path)?.read_to_string(&mut toml_str)?;
583 match toml::from_str::<AppSettings>(&toml_str) {
584 Ok(mut setting) => {
585 setting.config_file = Some(path.clone());
586
587 let config_dir = path.parent().unwrap_or(std::path::Path::new("."));
589 setting.resolve_paths(config_dir);
590
591 if config_migration::ConfigMigration::run(&mut setting)? {
593 setting.save_to_disk()?;
594 }
595
596 setting.merge_missing_default_bindings();
597 Ok(setting)
598 }
599 Err(e) => {
600 tracing::warn!(
601 "Config file at {:?} could not be parsed ({}). \
602 Renaming to .corrupt and starting with defaults.",
603 path,
604 e
605 );
606 let corrupt_path = path.with_extension("toml.corrupt");
607 let _ = fs::rename(&path, &corrupt_path);
608 let defaults = Self {
609 config_file: Some(path),
610 ..Self::default()
611 };
612 defaults.save_to_disk()?;
613 Ok(defaults)
614 }
615 }
616 }
617
618 fn merge_missing_default_bindings(&mut self) {
624 let defaults = default_keybindings().to_hashmap();
625 let mut current = self.key_bindings.to_hashmap();
626 let mut bound: std::collections::HashSet<_> = current.values().flatten().cloned().collect();
627 for (action, combos) in defaults {
628 match current.entry(action) {
629 std::collections::hash_map::Entry::Vacant(e) => {
630 let free: Vec<_> = combos.into_iter().filter(|c| !bound.contains(c)).collect();
634 if !free.is_empty() {
635 bound.extend(free.iter().copied());
636 e.insert(free);
637 }
638 }
639 std::collections::hash_map::Entry::Occupied(mut e) => {
640 for combo in combos {
641 if !bound.contains(&combo) && !e.get().contains(&combo) {
642 bound.insert(combo);
643 e.get_mut().push(combo);
644 }
645 }
646 }
647 }
648 }
649 self.key_bindings = KeyBindings::from_hashmap(current);
650 }
651
652 pub fn set_workspace(&mut self, workspace_path: &PathBuf) {
655 if let Some(current_workspace_dir) = &self.workspace_dir
656 && workspace_path != current_workspace_dir
657 {
658 self.needs_indexing = true;
659 }
660
661 self.workspace_dir = Some(workspace_path.to_owned());
662 }
663
664 pub fn clear_workspace(&mut self) {
671 if self.workspace_dir.is_some() {
673 self.workspace_dir = None;
674 self.needs_indexing = true;
675 }
676 if let Some(wc) = &mut self.workspace_config {
678 let key = wc.global.current_workspace.clone();
679 if !key.is_empty() {
680 wc.workspaces.remove(&key);
681 }
682 wc.global.current_workspace = String::new();
683 }
684 }
685
686 pub fn resolve_workspace_path(&self) -> Option<PathBuf> {
689 self.workspace_config
690 .as_ref()
691 .and_then(|wc| wc.get_current_workspace())
692 .map(|entry| entry.effective_path().clone())
693 .or_else(|| self.workspace_dir.clone())
694 }
695
696 fn resolve_paths(&mut self, base: &std::path::Path) {
700 if let Some(ref mut p) = self.workspace_dir {
703 *p = Self::expand_path(p, base);
704 }
705 if let Some(ref mut wc) = self.workspace_config {
707 for entry in wc.workspaces.values_mut() {
708 let resolved = Self::expand_path(&entry.path, base);
709 if resolved != entry.path {
710 entry.resolved_path = Some(resolved);
711 }
712 }
713 }
714 self.cache_dir_resolved = Some(Self::expand_path(&self.cache_dir, base));
715 self.history_dir_resolved = Some(Self::expand_path(&self.history_dir, base));
716 }
717
718 fn expand_path(path: &std::path::Path, base: &std::path::Path) -> PathBuf {
722 let s = path.to_string_lossy();
723 let expanded = if s.starts_with("~/") || s == "~" {
724 if let Ok(home) = config_dir::get_home_dir() {
725 home.join(s.strip_prefix("~/").unwrap_or(""))
726 } else {
727 path.to_path_buf()
728 }
729 } else {
730 path.to_path_buf()
731 };
732 let absolute = if expanded.is_relative() {
733 base.join(expanded)
734 } else {
735 expanded
736 };
737 absolute.canonicalize().unwrap_or(absolute)
739 }
740
741 pub fn set_theme(&mut self, theme: String) {
742 self.theme = theme;
743 }
744
745 pub fn report_indexed(&mut self) {
746 self.needs_indexing = false;
747 }
748
749 pub fn needs_indexing(&self) -> bool {
750 self.needs_indexing
751 }
752
753 pub fn add_path_history(&mut self, note_path: &VaultPath) {
754 if !note_path.is_note() {
755 return;
756 }
757 let Some(workspace_name) = self.current_workspace_name() else {
758 return;
759 };
760 let file_path = self.history_path_for(&workspace_name);
761 if let Err(e) = history::push_history(&file_path, note_path) {
762 tracing::warn!("failed to write history {:?}: {}", file_path, e);
763 }
764 }
765
766 pub fn current_workspace_name(&self) -> Option<String> {
767 self.workspace_config
768 .as_ref()
769 .map(|wc| wc.global.current_workspace.clone())
770 .filter(|s| !s.is_empty())
771 }
772
773 pub fn cache_dir_resolved(&self) -> Option<&Path> {
774 self.cache_dir_resolved.as_deref()
775 }
776
777 pub fn history_dir_resolved(&self) -> Option<&Path> {
778 self.history_dir_resolved.as_deref()
779 }
780
781 pub fn cache_path_for(&self, workspace_name: &str) -> PathBuf {
785 Self::workspace_file(
786 self.cache_dir_resolved.as_ref().unwrap_or(&self.cache_dir),
787 workspace_name,
788 CACHE_FILE_EXT,
789 )
790 }
791
792 pub fn history_path_for(&self, workspace_name: &str) -> PathBuf {
795 Self::workspace_file(
796 self.history_dir_resolved
797 .as_ref()
798 .unwrap_or(&self.history_dir),
799 workspace_name,
800 HISTORY_FILE_EXT,
801 )
802 }
803
804 fn workspace_file(dir: &Path, workspace_name: &str, ext: &str) -> PathBuf {
805 dir.join(format!("{workspace_name}.{ext}"))
806 }
807
808 pub fn current_last_paths(&self) -> Vec<VaultPath> {
810 let Some(name) = self.current_workspace_name() else {
811 return Vec::new();
812 };
813 let file_path = self.history_path_for(&name);
814 history::load_history(&file_path)
815 }
816
817 pub fn icons(&self) -> icons::Icons {
819 icons::Icons::new(self.use_nerd_fonts)
820 }
821
822 pub fn effective_theme_name(&self) -> String {
826 if self.theme.is_empty() {
827 Theme::default().name
828 } else {
829 self.theme.clone()
830 }
831 }
832
833 pub fn get_theme(&self) -> Theme {
839 let theme = if self.theme.is_empty() {
840 Theme::default()
841 } else {
842 self.theme_list()
843 .into_iter()
844 .find(|t| t.name == self.theme)
845 .unwrap_or_default()
846 };
847 theme.adapt_to_terminal()
848 }
849}
850
851#[cfg(test)]
852#[allow(clippy::field_reassign_with_default)]
853mod tests {
854 use super::*;
855
856 #[test]
857 fn default_workspace_suggestion_is_under_home() {
858 let suggestion = AppSettings::default_workspace_suggestion();
859 if let Some(p) = suggestion {
860 assert!(p.ends_with("kimun-notes"));
861 assert!(p.is_absolute());
862 }
863 }
865
866 #[test]
867 fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
868 let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
871 let _ = std::fs::remove_file(&path); let result = AppSettings::load_theme_from_path(&path);
874
875 assert!(result.is_err(), "should return Err when file is absent");
876 assert!(!path.exists(), "must not create the file as a side effect");
877 }
878
879 #[test]
880 fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
881 let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
883 std::fs::write(&path, b"not valid toml {{{{").unwrap();
884
885 let result = AppSettings::load_theme_from_path(&path);
886
887 assert!(result.is_err(), "should return Err for corrupt TOML");
888 assert!(path.exists(), "corrupt theme file must not be deleted");
891 std::fs::remove_file(&path).ok();
892 }
893
894 #[test]
895 fn default_keybindings_quit_matches_canonical_combo() {
896 let kb = default_keybindings();
897 let combo = crate::keys::default_quit_combo();
898 assert_eq!(
899 kb.get_action(&combo),
900 Some(ActionShortcuts::Quit),
901 "default_keybindings() must bind default_quit_combo() to Quit so the \
902 deserialize safety net can recover an unreachable app"
903 );
904 }
905
906 #[test]
907 fn autosave_interval_defaults_to_five() {
908 let settings = AppSettings::default();
909 assert_eq!(settings.autosave_interval_secs, 5);
910 }
911
912 #[test]
913 fn autosave_interval_deserializes_from_toml() {
914 let toml = "autosave_interval_secs = 30\n";
915 let settings: AppSettings = toml::from_str(toml).unwrap();
916 assert_eq!(settings.autosave_interval_secs, 30);
917 }
918
919 #[test]
920 fn autosave_interval_defaults_when_missing_from_toml() {
921 let toml = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
923 assert_eq!(settings.autosave_interval_secs, 5);
924 }
925
926 #[test]
928 fn f2_file_operations_survives_toml_deserialize() {
929 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
930 use crate::keys::key_strike::KeyStrike;
931
932 let toml = r#"
933[key_bindings]
934FileOperations = ["F2"]
935"#;
936 let settings: AppSettings = toml::from_str(toml).unwrap();
937 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
938 let action = settings.key_bindings.get_action(&f2);
939 assert_eq!(
940 action,
941 Some(ActionShortcuts::FileOperations),
942 "F2 should survive deserialization and map to FileOperations"
943 );
944 }
945
946 #[test]
948 fn merge_adds_f2_when_absent() {
949 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
950 use crate::keys::key_strike::KeyStrike;
951
952 let toml = r#"
954[key_bindings]
955Quit = ["ctrl&Q"]
956"#;
957 let mut settings: AppSettings = toml::from_str(toml).unwrap();
958 settings.merge_missing_default_bindings();
959
960 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
961 let action = settings.key_bindings.get_action(&f2);
962 assert_eq!(
963 action,
964 Some(ActionShortcuts::FileOperations),
965 "merge_missing_default_bindings should add F2 → FileOperations"
966 );
967 }
968
969 #[test]
970 fn clear_workspace_phase1_clears_workspace_dir() {
971 let mut settings = AppSettings::default();
972 settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
973 settings.needs_indexing = false;
974 settings.clear_workspace();
975 assert!(
976 settings.workspace_dir.is_none(),
977 "workspace_dir should be None"
978 );
979 assert!(
980 settings.needs_indexing,
981 "needs_indexing should be reset to true"
982 );
983 }
984
985 #[test]
986 fn clear_workspace_phase2_removes_current_workspace_entry() {
987 let mut settings = AppSettings::default();
988 let mut wc = WorkspaceConfig::new_empty();
989 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
990 .unwrap();
991 settings.workspace_config = Some(wc);
992 assert_eq!(
994 settings
995 .workspace_config
996 .as_ref()
997 .unwrap()
998 .global
999 .current_workspace,
1000 "vault1"
1001 );
1002 settings.clear_workspace();
1003 let wc = settings.workspace_config.as_ref().unwrap();
1004 assert!(
1005 wc.workspaces.is_empty(),
1006 "workspace entry should be removed"
1007 );
1008 assert!(
1009 wc.global.current_workspace.is_empty(),
1010 "current_workspace should be empty"
1011 );
1012 }
1013
1014 #[test]
1015 fn clear_workspace_both_phases_active() {
1016 let mut settings = AppSettings::default();
1019 settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
1020 let mut wc = WorkspaceConfig::new_empty();
1021 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
1022 .unwrap();
1023 settings.workspace_config = Some(wc);
1024 settings.clear_workspace();
1025 assert!(
1026 settings.workspace_dir.is_none(),
1027 "phase1 workspace_dir should be cleared"
1028 );
1029 let wc = settings.workspace_config.as_ref().unwrap();
1030 assert!(
1031 wc.workspaces.is_empty(),
1032 "phase2 workspace entry should be removed"
1033 );
1034 assert!(
1035 wc.global.current_workspace.is_empty(),
1036 "phase2 current_workspace should be empty"
1037 );
1038 }
1039
1040 #[test]
1041 fn clear_workspace_phase2_preserves_other_workspaces() {
1042 let mut settings = AppSettings::default();
1043 let mut wc = WorkspaceConfig::new_empty();
1044 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
1045 .unwrap();
1046 wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
1047 .unwrap();
1048 wc.global.current_workspace = "vault1".to_string();
1049 settings.workspace_config = Some(wc);
1050 settings.clear_workspace();
1051 let wc = settings.workspace_config.as_ref().unwrap();
1052 assert!(
1053 !wc.workspaces.contains_key("vault1"),
1054 "active workspace should be removed"
1055 );
1056 assert!(
1057 wc.workspaces.contains_key("vault2"),
1058 "other workspaces should be preserved"
1059 );
1060 assert!(
1061 wc.global.current_workspace.is_empty(),
1062 "current_workspace should be empty"
1063 );
1064 }
1065}
1066
1067#[cfg(test)]
1068mod backend_tests {
1069 use super::*;
1070
1071 #[test]
1072 fn default_backend_is_textarea() {
1073 let settings = AppSettings::default();
1074 assert!(matches!(
1075 settings.editor_backend,
1076 EditorBackendSetting::Textarea
1077 ));
1078 }
1079
1080 #[test]
1081 fn nvim_backend_round_trips_toml() {
1082 let toml = "editor_backend = \"nvim\"\n";
1083 let parsed: AppSettings = toml::from_str(toml).unwrap();
1084 assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
1085 }
1086
1087 #[test]
1088 fn editor_backend_vim_roundtrips_through_toml() {
1089 #[derive(serde::Serialize, serde::Deserialize)]
1090 struct W {
1091 editor_backend: EditorBackendSetting,
1092 }
1093 let w = W {
1094 editor_backend: EditorBackendSetting::Vim,
1095 };
1096 let s = toml::to_string(&w).unwrap();
1097 assert!(s.contains("editor_backend = \"vim\""), "serialized: {s}");
1098 let back: W = toml::from_str(&s).unwrap();
1099 assert_eq!(back.editor_backend, EditorBackendSetting::Vim);
1100 }
1101
1102 #[test]
1105 fn expand_path_absolute_unchanged() {
1106 let base = PathBuf::from("/config/dir");
1107 let result = AppSettings::expand_path(std::path::Path::new("/absolute/path/notes"), &base);
1108 assert!(result.is_absolute());
1109 assert!(result.to_string_lossy().contains("absolute"));
1110 }
1111
1112 #[test]
1113 fn expand_path_relative_resolved_against_base() {
1114 let base = tempfile::TempDir::new().unwrap();
1115 let notes = base.path().join("notes");
1116 std::fs::create_dir_all(¬es).unwrap();
1117
1118 let result = AppSettings::expand_path(std::path::Path::new("notes"), base.path());
1119 assert!(result.is_absolute());
1120 assert_eq!(result, notes.canonicalize().unwrap());
1121 }
1122
1123 #[test]
1124 fn expand_path_relative_with_dotdot() {
1125 let base = tempfile::TempDir::new().unwrap();
1126 let sibling = base.path().join("sibling");
1127 std::fs::create_dir_all(&sibling).unwrap();
1128 let sub = base.path().join("sub");
1129 std::fs::create_dir_all(&sub).unwrap();
1130
1131 let result = AppSettings::expand_path(std::path::Path::new("../sibling"), &sub);
1132 assert!(result.is_absolute());
1133 assert_eq!(result, sibling.canonicalize().unwrap());
1134 }
1135
1136 #[test]
1137 fn expand_path_nonexistent_relative_still_absolute() {
1138 let base = PathBuf::from("/some/config/dir");
1139 let result = AppSettings::expand_path(std::path::Path::new("my-notes"), &base);
1140 assert!(result.is_absolute());
1141 assert_eq!(result, PathBuf::from("/some/config/dir/my-notes"));
1142 }
1143
1144 #[test]
1145 #[cfg(unix)]
1146 fn expand_path_tilde_uses_home_unix() {
1147 let home = std::env::var("HOME").expect("HOME must be set on Unix");
1148 let base = PathBuf::from("/irrelevant");
1149 let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
1150 assert!(result.is_absolute());
1151 assert!(
1152 result.starts_with(&home),
1153 "expected path to start with HOME={}, got {:?}",
1154 home,
1155 result
1156 );
1157 assert!(result.to_string_lossy().contains("Documents/notes"));
1158 }
1159
1160 #[test]
1161 #[cfg(unix)]
1162 fn expand_path_tilde_alone_is_home_unix() {
1163 let home = std::env::var("HOME").expect("HOME must be set on Unix");
1164 let base = PathBuf::from("/irrelevant");
1165 let result = AppSettings::expand_path(std::path::Path::new("~"), &base);
1166 assert!(result.is_absolute());
1167 let expected = PathBuf::from(&home)
1169 .canonicalize()
1170 .unwrap_or(PathBuf::from(&home));
1171 assert_eq!(result, expected);
1172 }
1173
1174 #[test]
1175 #[cfg(windows)]
1176 fn expand_path_tilde_uses_userprofile_windows() {
1177 let home = std::env::var("USERPROFILE").expect("USERPROFILE must be set on Windows");
1178 let base = PathBuf::from("C:\\irrelevant");
1179 let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
1180 assert!(result.is_absolute());
1181 assert!(
1182 result.starts_with(&home),
1183 "expected path to start with USERPROFILE={}, got {:?}",
1184 home,
1185 result
1186 );
1187 }
1188
1189 #[test]
1190 fn resolve_paths_populates_resolved_path() {
1191 let base = tempfile::TempDir::new().unwrap();
1192 let notes = base.path().join("notes");
1193 std::fs::create_dir_all(¬es).unwrap();
1194
1195 let toml = r#"
1196config_version = 2
1197[global]
1198current_workspace = "test"
1199[workspaces.test]
1200path = "notes"
1201last_paths = []
1202created = "2026-01-01T00:00:00Z"
1203"#
1204 .to_string();
1205 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1206 settings.resolve_paths(base.path());
1207
1208 let wc = settings.workspace_config.as_ref().unwrap();
1209 let entry = wc.workspaces.get("test").unwrap();
1210 assert_eq!(entry.path, PathBuf::from("notes"));
1212 assert!(entry.resolved_path.is_some());
1214 assert!(entry.effective_path().is_absolute());
1215 }
1216
1217 #[test]
1218 fn resolve_paths_absolute_no_resolved_path() {
1219 let toml = r#"
1220config_version = 2
1221[global]
1222current_workspace = "test"
1223[workspaces.test]
1224path = "/absolute/notes"
1225last_paths = []
1226created = "2026-01-01T00:00:00Z"
1227"#;
1228 let mut settings: AppSettings = toml::from_str(toml).unwrap();
1229 settings.resolve_paths(std::path::Path::new("/config"));
1230
1231 let wc = settings.workspace_config.as_ref().unwrap();
1232 let entry = wc.workspaces.get("test").unwrap();
1233 assert!(entry.resolved_path.is_none());
1235 assert_eq!(*entry.effective_path(), PathBuf::from("/absolute/notes"));
1236 }
1237}
1238
1239#[cfg(test)]
1240mod sort_settings_tests {
1241 use super::*;
1242
1243 #[test]
1244 fn group_directories_defaults_off() {
1245 let s = AppSettings::default();
1246 assert!(!s.group_directories);
1247 }
1248
1249 #[test]
1250 fn open_sort_dialog_is_bound_by_default() {
1251 let s = AppSettings::default();
1252 let map = s.key_bindings.to_hashmap();
1253 assert!(
1254 map.contains_key(&ActionShortcuts::OpenSortDialog),
1255 "OpenSortDialog must have a default binding"
1256 );
1257 }
1258}