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]
68 #[serde(alias = "textarea")]
69 Plain,
70 Nvim,
71 Vim,
72}
73
74#[cfg(debug_assertions)]
77const CONFIG_DIR: &str = "kimun_debug";
78#[cfg(not(debug_assertions))]
79const CONFIG_DIR: &str = "kimun";
80
81pub fn config_dir() -> std::io::Result<PathBuf> {
86 get_or_create_config_dir(CONFIG_DIR)
87}
88
89const BASE_CONFIG_FILE: &str = "config.toml";
90const THEMES_DIR: &str = "themes";
91const CACHE_FILE_EXT: &str = "kimuncache";
92const HISTORY_FILE_EXT: &str = "txt";
93
94const CONFIG_HEADER: &str = "\
95# ─── Kimün configuration ────────────────────────────────────────────────────
96#
97# KEY BINDINGS
98# ────────────
99# Supported combinations:
100# - ctrl and/or alt (with optional shift) + a letter (a-z)
101# - bare F-key (F1–F12, no modifier required)
102# Any combo that does not follow these rules is silently ignored when loaded.
103#
104# Format per action:
105# ActionName = [\"<modifiers> & <letter>\", ...]
106#
107# Available modifiers (combine with +): ctrl alt shift
108#
109# Examples:
110# Quit = [\"ctrl&Q\"] # Ctrl+Q
111# SearchNotes = [\"ctrl&K\"] # Ctrl+K
112# OpenNote = [\"ctrl&O\"] # Ctrl+O (fuzzy file finder)
113# OpenSettings = [\"F4\", \"ctrl&,\"] # F4 (Ctrl+, alias)
114# NewJournal = [\"ctrl&J\"] # Ctrl+J
115# FileOperations = [\"F2\"] # F2 (open file-ops menu: delete/rename/move)
116# Leader = [\"ctrl&G\"] # Ctrl+G (leader gateway: Ctrl+G f f, ...)
117# OpenCommandPalette = [\"ctrl&P\"] # Ctrl+P (every leader command, fuzzy)
118#
119# OTHER SETTINGS
120# ──────────────
121# theme = \"Gruvbox Dark\" # or any built-in / custom theme name
122# leader_timeout_ms = 400 # hesitation before the which-key menu
123#
124# LEADER TREE OVERRIDES
125# ─────────────────────
126# Remap, add, or remove leader sequences ([leader.bind]) and rename group
127# captions ([leader.labels]). Keys are the sequence AFTER the gateway;
128# bind values are action ids (see the cheatsheet) or \"none\" to unbind.
129# [leader.bind]
130# \"o f\" = \"find.files\" # remap: leader o f now opens the file picker
131# \"x\" = \"note.daily\" # add: leader x opens today's journal
132# \"g p\" = \"none\" # remove the git-sync stub binding
133# [leader.labels]
134# \"f\" = \"+search\" # rename the +find group caption
135#
136# ─────────────────────────────────────────────────────────────────────────────
137";
138
139#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
140pub struct AppSettings {
141 #[serde(default)]
143 pub config_version: u32,
144 #[serde(flatten, skip_serializing_if = "Option::is_none")]
145 pub workspace_config: Option<WorkspaceConfig>,
146
147 #[serde(skip_serializing_if = "Option::is_none")]
151 pub workspace_dir: Option<PathBuf>,
152 #[serde(default, skip_serializing)]
153 pub last_paths: Vec<VaultPath>,
154
155 #[serde(default)]
157 pub theme: String,
158 #[serde(default = "default_cache_dir")]
159 pub cache_dir: PathBuf,
160 #[serde(skip)]
161 cache_dir_resolved: Option<PathBuf>,
162
163 #[serde(default = "default_history_dir")]
164 pub history_dir: PathBuf,
165 #[serde(skip)]
166 history_dir_resolved: Option<PathBuf>,
167 #[serde(skip, default = "yes")]
168 needs_indexing: bool,
169 #[serde(default = "default_keybindings")]
170 pub key_bindings: KeyBindings,
171 #[serde(default = "default_autosave_interval")]
172 pub autosave_interval_secs: u64,
173 #[serde(default = "default_leader_timeout_ms")]
176 pub leader_timeout_ms: u64,
177 #[serde(default)]
181 pub leader: LeaderConfig,
182 #[serde(default = "default_use_nerd_fonts")]
183 pub use_nerd_fonts: bool,
184 #[serde(default)]
185 pub editor_backend: EditorBackendSetting,
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub nvim_path: Option<std::path::PathBuf>,
188 #[serde(default = "default_sort_field")]
189 pub default_sort_field: SortFieldSetting,
190 #[serde(default = "default_sort_order")]
191 pub default_sort_order: SortOrderSetting,
192 #[serde(default = "default_journal_sort_field")]
193 pub journal_sort_field: SortFieldSetting,
194 #[serde(default = "default_journal_sort_order")]
195 pub journal_sort_order: SortOrderSetting,
196 #[serde(default)]
197 pub group_directories: bool,
198 #[serde(skip)]
201 pub config_file: Option<PathBuf>,
202}
203
204fn default_keybindings() -> KeyBindings {
205 let mut kb = KeyBindings::empty();
206 kb.batch_add()
207 .with_ctrl()
208 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
209 .add(KeyStrike::KeyO, ActionShortcuts::OpenNote)
210 .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
211 .add(KeyStrike::KeyI, ActionShortcuts::Text(TextAction::Italic))
212 .add(
213 KeyStrike::KeyU,
214 ActionShortcuts::Text(TextAction::Underline),
215 )
216 .add(
217 KeyStrike::KeyS,
218 ActionShortcuts::Text(TextAction::Strikethrough),
219 )
220 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Link))
221 .add(
222 KeyStrike::KeyT,
223 ActionShortcuts::Text(TextAction::ToggleHeader),
224 )
225 .with_shift()
229 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
230
231 kb.batch_add()
235 .with_ctrl()
236 .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
239 .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
240 .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
241 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
245 .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
246 .add(KeyStrike::KeyG, ActionShortcuts::Leader)
249 .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
252 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
253 .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
254 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
255 .add(KeyStrike::KeyE, ActionShortcuts::OpenFileBrowser)
259 .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer)
260 .add(
265 crate::keys::default_yank_combo().key,
266 ActionShortcuts::YankRow,
267 );
268
269 kb.batch_add()
275 .add(KeyStrike::F4, ActionShortcuts::OpenPreferences);
276 kb.batch_add()
277 .with_ctrl()
278 .add(KeyStrike::Comma, ActionShortcuts::OpenPreferences);
279
280 kb.batch_add()
282 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
283
284 kb.batch_add()
285 .add(KeyStrike::F3, ActionShortcuts::OpenSavedSearches);
286
287 kb.batch_add().add(KeyStrike::F6, ActionShortcuts::OpenAsk);
289
290 kb.batch_add()
292 .add(KeyStrike::F5, ActionShortcuts::SwitchWorkspace);
293
294 kb.batch_add()
299 .with_ctrl()
300 .add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
301
302 kb
303}
304
305fn yes() -> bool {
306 true
307}
308
309fn default_autosave_interval() -> u64 {
310 5
311}
312
313fn default_leader_timeout_ms() -> u64 {
314 400
315}
316
317#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
319pub struct LeaderConfig {
320 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
323 pub bind: std::collections::BTreeMap<String, String>,
324 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
327 pub labels: std::collections::BTreeMap<String, String>,
328}
329
330impl AppSettings {
331 pub fn default_workspace_suggestion() -> Option<PathBuf> {
334 config_dir::get_home_dir()
335 .ok()
336 .map(|h| h.join("kimun-notes"))
337 }
338
339 pub fn leader_tree(&self) -> crate::keys::leader::LeaderNode {
343 let tree = crate::keys::leader::apply_overrides(
344 crate::keys::leader::leader_tree(),
345 self.leader
346 .bind
347 .iter()
348 .map(|(k, v)| (k.as_str(), v.as_str())),
349 );
350 crate::keys::leader::apply_labels(
351 tree,
352 self.leader
353 .labels
354 .iter()
355 .map(|(k, v)| (k.as_str(), v.as_str())),
356 )
357 }
358}
359
360fn default_cache_dir() -> PathBuf {
361 PathBuf::from(".")
362}
363
364fn default_history_dir() -> PathBuf {
365 PathBuf::from("history")
366}
367
368fn default_use_nerd_fonts() -> bool {
369 false
370}
371
372fn default_sort_field() -> SortFieldSetting {
373 SortFieldSetting::Name
374}
375
376fn default_sort_order() -> SortOrderSetting {
377 SortOrderSetting::Ascending
378}
379
380fn default_journal_sort_field() -> SortFieldSetting {
381 SortFieldSetting::Name
382}
383
384fn default_journal_sort_order() -> SortOrderSetting {
385 SortOrderSetting::Descending
386}
387
388impl Default for AppSettings {
389 fn default() -> Self {
390 Self {
391 config_version: 0,
392 workspace_config: None,
393 last_paths: vec![],
394 workspace_dir: None,
395 theme: Default::default(),
396 cache_dir: default_cache_dir(),
397 cache_dir_resolved: None,
398 history_dir: default_history_dir(),
399 history_dir_resolved: None,
400 needs_indexing: true,
401 key_bindings: default_keybindings(),
402 autosave_interval_secs: default_autosave_interval(),
403 leader_timeout_ms: default_leader_timeout_ms(),
404 leader: LeaderConfig::default(),
405 use_nerd_fonts: false,
406 editor_backend: EditorBackendSetting::Plain,
407 nvim_path: None,
408 default_sort_field: default_sort_field(),
409 default_sort_order: default_sort_order(),
410 journal_sort_field: default_journal_sort_field(),
411 journal_sort_order: default_journal_sort_order(),
412 group_directories: false,
413 config_file: None,
414 }
415 }
416}
417
418impl AppSettings {
419 pub fn theme_list(&self) -> Vec<Theme> {
420 let mut list = Theme::builtins();
421 list.append(&mut Self::load_custom_themes());
422 if let Ok(custom_default) = Self::load_default_theme() {
424 list.push(custom_default);
425 }
426 list.sort_by(|a, b| a.name.cmp(&b.name));
427 list
428 }
429
430 fn default_config_file_path() -> Result<PathBuf, SettingsError> {
431 let config_home = get_or_create_config_dir(CONFIG_DIR)?;
432 Ok(config_home.join(BASE_CONFIG_FILE))
433 }
434
435 fn get_config_file_path(&self) -> Result<PathBuf, SettingsError> {
436 if let Some(ref path) = self.config_file {
437 Ok(path.clone())
438 } else {
439 Self::default_config_file_path()
440 }
441 }
442
443 fn get_themes_path() -> Result<PathBuf, SettingsError> {
444 let config_home = get_or_create_config_dir(CONFIG_DIR)?;
445 Ok(config_home.join(THEMES_DIR))
446 }
447
448 fn load_theme_from_path(path: &std::path::Path) -> Result<Theme, SettingsError> {
449 let theme_string = fs::read_to_string(path)?;
450 match toml::from_str::<Theme>(&theme_string) {
451 Ok(theme) => Ok(theme),
452 Err(e) => {
453 tracing::warn!("Skipping unparsable theme file {:?}: {}", path, e);
456 Err(SettingsError::CorruptTheme(e))
457 }
458 }
459 }
460
461 fn load_default_theme() -> Result<Theme, SettingsError> {
462 let theme_path = AppSettings::get_themes_path()?.join("default.toml");
463 Self::load_theme_from_path(&theme_path)
464 }
465
466 fn load_custom_themes() -> Vec<Theme> {
467 let mut themes = Vec::new();
468
469 let themes_path = match Self::get_themes_path() {
471 Ok(path) => path,
472 Err(_) => return themes,
473 };
474
475 let entries = match fs::read_dir(&themes_path) {
477 Ok(entries) => entries,
478 Err(_) => return themes,
479 };
480
481 for entry in entries.flatten() {
483 let path = entry.path();
484
485 if !path.is_file() {
487 continue;
488 }
489
490 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
492 continue;
493 }
494
495 if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
497 continue;
498 }
499
500 match fs::read_to_string(&path)
502 .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
503 {
504 Ok(theme) => themes.push(theme),
505 Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
506 }
507 }
508
509 themes
510 }
511
512 pub fn update_check(&self) -> bool {
516 self.workspace_config
517 .as_ref()
518 .map(|wc| wc.global.update_check)
519 .unwrap_or(true)
520 }
521
522 pub fn mouse(&self) -> bool {
525 self.workspace_config
526 .as_ref()
527 .map(|wc| wc.global.mouse)
528 .unwrap_or(true)
529 }
530
531 pub fn save_to_disk(&self) -> Result<(), SettingsError> {
532 tracing::debug!("Saving settings to disk");
533 let settings_file_path = self.get_config_file_path()?;
534 let mut file = File::create(settings_file_path)?;
535 file.write_all(CONFIG_HEADER.as_bytes())?;
536 let toml = toml::to_string(&self)?;
537 file.write_all(toml.as_bytes())?;
538 Ok(())
539 }
540
541 pub fn load_from_disk() -> Result<Self, SettingsError> {
542 let settings_file_path = Self::default_config_file_path()?;
543
544 if !settings_file_path.exists() {
545 let default_settings = Self::default();
546 default_settings.save_to_disk()?;
547 Ok(default_settings)
548 } else {
549 let mut settings_file = File::open(&settings_file_path)?;
550
551 let mut toml = String::new();
552 settings_file.read_to_string(&mut toml)?;
553
554 match toml::from_str::<AppSettings>(toml.as_ref()) {
555 Ok(mut setting) => {
556 setting.config_file = Some(settings_file_path.clone());
557 let config_dir = settings_file_path
558 .parent()
559 .unwrap_or(std::path::Path::new("."));
560 setting.resolve_paths(config_dir);
561 if config_migration::ConfigMigration::run(&mut setting)? {
562 setting.save_to_disk()?;
563 }
564 setting.merge_missing_default_bindings();
565 Ok(setting)
566 }
567 Err(e) => {
568 tracing::warn!(
569 "Config file at {:?} could not be parsed ({}). \
570 Renaming to .corrupt and starting with defaults.",
571 settings_file_path,
572 e
573 );
574 let corrupt_path = settings_file_path.with_extension("toml.corrupt");
575 let _ = fs::rename(&settings_file_path, &corrupt_path);
576 let defaults = Self::default();
577 defaults.save_to_disk()?;
578 Ok(defaults)
579 }
580 }
581 }
582 }
583
584 pub fn load_from_file(path: PathBuf) -> Result<Self, SettingsError> {
585 if let Some(parent) = path.parent() {
586 fs::create_dir_all(parent)?;
587 }
588 if !path.exists() {
589 let default_settings = Self {
590 config_file: Some(path),
591 ..Self::default()
592 };
593 default_settings.save_to_disk()?;
594 return Ok(default_settings);
595 }
596 let mut toml_str = String::new();
597 File::open(&path)?.read_to_string(&mut toml_str)?;
598 match toml::from_str::<AppSettings>(&toml_str) {
599 Ok(mut setting) => {
600 setting.config_file = Some(path.clone());
601
602 let config_dir = path.parent().unwrap_or(std::path::Path::new("."));
604 setting.resolve_paths(config_dir);
605
606 if config_migration::ConfigMigration::run(&mut setting)? {
608 setting.save_to_disk()?;
609 }
610
611 setting.merge_missing_default_bindings();
612 Ok(setting)
613 }
614 Err(e) => {
615 tracing::warn!(
616 "Config file at {:?} could not be parsed ({}). \
617 Renaming to .corrupt and starting with defaults.",
618 path,
619 e
620 );
621 let corrupt_path = path.with_extension("toml.corrupt");
622 let _ = fs::rename(&path, &corrupt_path);
623 let defaults = Self {
624 config_file: Some(path),
625 ..Self::default()
626 };
627 defaults.save_to_disk()?;
628 Ok(defaults)
629 }
630 }
631 }
632
633 fn merge_missing_default_bindings(&mut self) {
639 let defaults = default_keybindings().to_hashmap();
640 let mut current = self.key_bindings.to_hashmap();
641 let mut bound: std::collections::HashSet<_> = current.values().flatten().cloned().collect();
642 for (action, combos) in defaults {
643 match current.entry(action) {
644 std::collections::hash_map::Entry::Vacant(e) => {
645 let free: Vec<_> = combos.into_iter().filter(|c| !bound.contains(c)).collect();
649 if !free.is_empty() {
650 bound.extend(free.iter().copied());
651 e.insert(free);
652 }
653 }
654 std::collections::hash_map::Entry::Occupied(mut e) => {
655 for combo in combos {
656 if !bound.contains(&combo) && !e.get().contains(&combo) {
657 bound.insert(combo);
658 e.get_mut().push(combo);
659 }
660 }
661 }
662 }
663 }
664 self.key_bindings = KeyBindings::from_hashmap(current);
665 }
666
667 pub fn set_workspace(&mut self, workspace_path: &PathBuf) {
670 if let Some(current_workspace_dir) = &self.workspace_dir
671 && workspace_path != current_workspace_dir
672 {
673 self.needs_indexing = true;
674 }
675
676 self.workspace_dir = Some(workspace_path.to_owned());
677 }
678
679 pub fn clear_workspace(&mut self) {
686 if self.workspace_dir.is_some() {
688 self.workspace_dir = None;
689 self.needs_indexing = true;
690 }
691 if let Some(wc) = &mut self.workspace_config {
693 let key = wc.global.current_workspace.clone();
694 if !key.is_empty() {
695 wc.workspaces.remove(&key);
696 }
697 wc.global.current_workspace = String::new();
698 }
699 }
700
701 pub fn resolve_workspace_path(&self) -> Option<PathBuf> {
704 self.workspace_config
705 .as_ref()
706 .and_then(|wc| wc.get_current_workspace())
707 .map(|entry| entry.effective_path().clone())
708 .or_else(|| self.workspace_dir.clone())
709 }
710
711 fn resolve_paths(&mut self, base: &std::path::Path) {
715 if let Some(ref mut p) = self.workspace_dir {
718 *p = Self::expand_path(p, base);
719 }
720 if let Some(ref mut wc) = self.workspace_config {
722 for entry in wc.workspaces.values_mut() {
723 let resolved = Self::expand_path(&entry.path, base);
724 if resolved != entry.path {
725 entry.resolved_path = Some(resolved);
726 }
727 }
728 }
729 self.cache_dir_resolved = Some(Self::expand_path(&self.cache_dir, base));
730 self.history_dir_resolved = Some(Self::expand_path(&self.history_dir, base));
731 }
732
733 fn expand_path(path: &std::path::Path, base: &std::path::Path) -> PathBuf {
737 let s = path.to_string_lossy();
738 let expanded = if s.starts_with("~/") || s == "~" {
739 if let Ok(home) = config_dir::get_home_dir() {
740 home.join(s.strip_prefix("~/").unwrap_or(""))
741 } else {
742 path.to_path_buf()
743 }
744 } else {
745 path.to_path_buf()
746 };
747 let absolute = if expanded.is_relative() {
748 base.join(expanded)
749 } else {
750 expanded
751 };
752 absolute.canonicalize().unwrap_or(absolute)
754 }
755
756 pub fn set_theme(&mut self, theme: String) {
757 self.theme = theme;
758 }
759
760 pub fn report_indexed(&mut self) {
761 self.needs_indexing = false;
762 }
763
764 pub fn needs_indexing(&self) -> bool {
765 self.needs_indexing
766 }
767
768 pub fn add_path_history(&mut self, note_path: &VaultPath) {
769 if !note_path.is_note() {
770 return;
771 }
772 let Some(workspace_name) = self.current_workspace_name() else {
773 return;
774 };
775 let file_path = self.history_path_for(&workspace_name);
776 if let Err(e) = history::push_history(&file_path, note_path) {
777 tracing::warn!("failed to write history {:?}: {}", file_path, e);
778 }
779 }
780
781 pub fn current_workspace_name(&self) -> Option<String> {
782 self.workspace_config
783 .as_ref()
784 .map(|wc| wc.global.current_workspace.clone())
785 .filter(|s| !s.is_empty())
786 }
787
788 pub fn cache_dir_resolved(&self) -> Option<&Path> {
789 self.cache_dir_resolved.as_deref()
790 }
791
792 pub fn history_dir_resolved(&self) -> Option<&Path> {
793 self.history_dir_resolved.as_deref()
794 }
795
796 pub fn cache_path_for(&self, workspace_name: &str) -> PathBuf {
800 Self::workspace_file(
801 self.cache_dir_resolved.as_ref().unwrap_or(&self.cache_dir),
802 workspace_name,
803 CACHE_FILE_EXT,
804 )
805 }
806
807 pub fn history_path_for(&self, workspace_name: &str) -> PathBuf {
810 Self::workspace_file(
811 self.history_dir_resolved
812 .as_ref()
813 .unwrap_or(&self.history_dir),
814 workspace_name,
815 HISTORY_FILE_EXT,
816 )
817 }
818
819 fn workspace_file(dir: &Path, workspace_name: &str, ext: &str) -> PathBuf {
820 dir.join(format!("{workspace_name}.{ext}"))
821 }
822
823 pub fn current_last_paths(&self) -> Vec<VaultPath> {
825 let Some(name) = self.current_workspace_name() else {
826 return Vec::new();
827 };
828 let file_path = self.history_path_for(&name);
829 history::load_history(&file_path)
830 }
831
832 pub fn icons(&self) -> icons::Icons {
834 icons::Icons::new(self.use_nerd_fonts)
835 }
836
837 pub fn yank_combos(&self) -> Vec<crate::keys::key_combo::KeyCombo> {
841 self.key_bindings.combos_for(&ActionShortcuts::YankRow)
842 }
843
844 pub fn effective_theme_name(&self) -> String {
848 if self.theme.is_empty() {
849 Theme::default().name
850 } else {
851 self.theme.clone()
852 }
853 }
854
855 pub fn get_theme(&self) -> Theme {
861 let theme = if self.theme.is_empty() {
862 Theme::default()
863 } else {
864 self.theme_list()
865 .into_iter()
866 .find(|t| t.name == self.theme)
867 .unwrap_or_default()
868 };
869 theme.adapt_to_terminal()
870 }
871}
872
873#[cfg(test)]
874#[allow(clippy::field_reassign_with_default)]
875mod tests {
876 use super::*;
877
878 #[test]
879 fn default_workspace_suggestion_is_under_home() {
880 let suggestion = AppSettings::default_workspace_suggestion();
881 if let Some(p) = suggestion {
882 assert!(p.ends_with("kimun-notes"));
883 assert!(p.is_absolute());
884 }
885 }
887
888 #[test]
889 fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
890 let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
893 let _ = std::fs::remove_file(&path); let result = AppSettings::load_theme_from_path(&path);
896
897 assert!(result.is_err(), "should return Err when file is absent");
898 assert!(!path.exists(), "must not create the file as a side effect");
899 }
900
901 #[test]
902 fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
903 let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
905 std::fs::write(&path, b"not valid toml {{{{").unwrap();
906
907 let result = AppSettings::load_theme_from_path(&path);
908
909 assert!(result.is_err(), "should return Err for corrupt TOML");
910 assert!(path.exists(), "corrupt theme file must not be deleted");
913 std::fs::remove_file(&path).ok();
914 }
915
916 #[test]
917 fn default_keybindings_quit_matches_canonical_combo() {
918 let kb = default_keybindings();
919 let combo = crate::keys::default_quit_combo();
920 assert_eq!(
921 kb.get_action(&combo),
922 Some(ActionShortcuts::Quit),
923 "default_keybindings() must bind default_quit_combo() to Quit so the \
924 deserialize safety net can recover an unreachable app"
925 );
926 }
927
928 #[test]
929 fn autosave_interval_defaults_to_five() {
930 let settings = AppSettings::default();
931 assert_eq!(settings.autosave_interval_secs, 5);
932 }
933
934 #[test]
935 fn autosave_interval_deserializes_from_toml() {
936 let toml = "autosave_interval_secs = 30\n";
937 let settings: AppSettings = toml::from_str(toml).unwrap();
938 assert_eq!(settings.autosave_interval_secs, 30);
939 }
940
941 #[test]
942 fn autosave_interval_defaults_when_missing_from_toml() {
943 let toml = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
945 assert_eq!(settings.autosave_interval_secs, 5);
946 }
947
948 #[test]
950 fn f2_file_operations_survives_toml_deserialize() {
951 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
952 use crate::keys::key_strike::KeyStrike;
953
954 let toml = r#"
955[key_bindings]
956FileOperations = ["F2"]
957"#;
958 let settings: AppSettings = toml::from_str(toml).unwrap();
959 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
960 let action = settings.key_bindings.get_action(&f2);
961 assert_eq!(
962 action,
963 Some(ActionShortcuts::FileOperations),
964 "F2 should survive deserialization and map to FileOperations"
965 );
966 }
967
968 #[test]
970 fn merge_adds_f2_when_absent() {
971 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
972 use crate::keys::key_strike::KeyStrike;
973
974 let toml = r#"
976[key_bindings]
977Quit = ["ctrl&Q"]
978"#;
979 let mut settings: AppSettings = toml::from_str(toml).unwrap();
980 settings.merge_missing_default_bindings();
981
982 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
983 let action = settings.key_bindings.get_action(&f2);
984 assert_eq!(
985 action,
986 Some(ActionShortcuts::FileOperations),
987 "merge_missing_default_bindings should add F2 → FileOperations"
988 );
989 }
990
991 #[test]
992 fn clear_workspace_phase1_clears_workspace_dir() {
993 let mut settings = AppSettings::default();
994 settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
995 settings.needs_indexing = false;
996 settings.clear_workspace();
997 assert!(
998 settings.workspace_dir.is_none(),
999 "workspace_dir should be None"
1000 );
1001 assert!(
1002 settings.needs_indexing,
1003 "needs_indexing should be reset to true"
1004 );
1005 }
1006
1007 #[test]
1008 fn clear_workspace_phase2_removes_current_workspace_entry() {
1009 let mut settings = AppSettings::default();
1010 let mut wc = WorkspaceConfig::new_empty();
1011 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
1012 .unwrap();
1013 settings.workspace_config = Some(wc);
1014 assert_eq!(
1016 settings
1017 .workspace_config
1018 .as_ref()
1019 .unwrap()
1020 .global
1021 .current_workspace,
1022 "vault1"
1023 );
1024 settings.clear_workspace();
1025 let wc = settings.workspace_config.as_ref().unwrap();
1026 assert!(
1027 wc.workspaces.is_empty(),
1028 "workspace entry should be removed"
1029 );
1030 assert!(
1031 wc.global.current_workspace.is_empty(),
1032 "current_workspace should be empty"
1033 );
1034 }
1035
1036 #[test]
1037 fn clear_workspace_both_phases_active() {
1038 let mut settings = AppSettings::default();
1041 settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
1042 let mut wc = WorkspaceConfig::new_empty();
1043 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
1044 .unwrap();
1045 settings.workspace_config = Some(wc);
1046 settings.clear_workspace();
1047 assert!(
1048 settings.workspace_dir.is_none(),
1049 "phase1 workspace_dir should be cleared"
1050 );
1051 let wc = settings.workspace_config.as_ref().unwrap();
1052 assert!(
1053 wc.workspaces.is_empty(),
1054 "phase2 workspace entry should be removed"
1055 );
1056 assert!(
1057 wc.global.current_workspace.is_empty(),
1058 "phase2 current_workspace should be empty"
1059 );
1060 }
1061
1062 #[test]
1063 fn clear_workspace_phase2_preserves_other_workspaces() {
1064 let mut settings = AppSettings::default();
1065 let mut wc = WorkspaceConfig::new_empty();
1066 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
1067 .unwrap();
1068 wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
1069 .unwrap();
1070 wc.global.current_workspace = "vault1".to_string();
1071 settings.workspace_config = Some(wc);
1072 settings.clear_workspace();
1073 let wc = settings.workspace_config.as_ref().unwrap();
1074 assert!(
1075 !wc.workspaces.contains_key("vault1"),
1076 "active workspace should be removed"
1077 );
1078 assert!(
1079 wc.workspaces.contains_key("vault2"),
1080 "other workspaces should be preserved"
1081 );
1082 assert!(
1083 wc.global.current_workspace.is_empty(),
1084 "current_workspace should be empty"
1085 );
1086 }
1087}
1088
1089#[cfg(test)]
1090mod backend_tests {
1091 use super::*;
1092
1093 #[test]
1094 fn a_config_still_saying_textarea_loads() {
1095 #[derive(serde::Deserialize)]
1099 struct Holder {
1100 editor_backend: EditorBackendSetting,
1101 }
1102 let old: Holder = toml::from_str("editor_backend = \"textarea\"").expect("still loads");
1103 assert_eq!(old.editor_backend, EditorBackendSetting::Plain);
1104 }
1105
1106 #[test]
1107 fn the_backend_is_written_back_as_plain() {
1108 let written = toml::to_string(&AppSettings::default()).expect("serialises");
1109 assert!(
1110 written.contains("editor_backend = \"plain\""),
1111 "a saved config should name the value as it is now: {written}"
1112 );
1113 }
1114
1115 #[test]
1116 fn default_backend_is_plain() {
1117 let settings = AppSettings::default();
1118 assert!(matches!(
1119 settings.editor_backend,
1120 EditorBackendSetting::Plain
1121 ));
1122 }
1123
1124 #[test]
1125 fn nvim_backend_round_trips_toml() {
1126 let toml = "editor_backend = \"nvim\"\n";
1127 let parsed: AppSettings = toml::from_str(toml).unwrap();
1128 assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
1129 }
1130
1131 #[test]
1132 fn editor_backend_vim_roundtrips_through_toml() {
1133 #[derive(serde::Serialize, serde::Deserialize)]
1134 struct W {
1135 editor_backend: EditorBackendSetting,
1136 }
1137 let w = W {
1138 editor_backend: EditorBackendSetting::Vim,
1139 };
1140 let s = toml::to_string(&w).unwrap();
1141 assert!(s.contains("editor_backend = \"vim\""), "serialized: {s}");
1142 let back: W = toml::from_str(&s).unwrap();
1143 assert_eq!(back.editor_backend, EditorBackendSetting::Vim);
1144 }
1145
1146 #[test]
1149 fn expand_path_absolute_unchanged() {
1150 let base = PathBuf::from("/config/dir");
1151 let result = AppSettings::expand_path(std::path::Path::new("/absolute/path/notes"), &base);
1152 assert!(result.is_absolute());
1153 assert!(result.to_string_lossy().contains("absolute"));
1154 }
1155
1156 #[test]
1157 fn expand_path_relative_resolved_against_base() {
1158 let base = tempfile::TempDir::new().unwrap();
1159 let notes = base.path().join("notes");
1160 std::fs::create_dir_all(¬es).unwrap();
1161
1162 let result = AppSettings::expand_path(std::path::Path::new("notes"), base.path());
1163 assert!(result.is_absolute());
1164 assert_eq!(result, notes.canonicalize().unwrap());
1165 }
1166
1167 #[test]
1168 fn expand_path_relative_with_dotdot() {
1169 let base = tempfile::TempDir::new().unwrap();
1170 let sibling = base.path().join("sibling");
1171 std::fs::create_dir_all(&sibling).unwrap();
1172 let sub = base.path().join("sub");
1173 std::fs::create_dir_all(&sub).unwrap();
1174
1175 let result = AppSettings::expand_path(std::path::Path::new("../sibling"), &sub);
1176 assert!(result.is_absolute());
1177 assert_eq!(result, sibling.canonicalize().unwrap());
1178 }
1179
1180 #[test]
1181 fn expand_path_nonexistent_relative_still_absolute() {
1182 let base = PathBuf::from("/some/config/dir");
1183 let result = AppSettings::expand_path(std::path::Path::new("my-notes"), &base);
1184 assert!(result.is_absolute());
1185 assert_eq!(result, PathBuf::from("/some/config/dir/my-notes"));
1186 }
1187
1188 #[test]
1189 #[cfg(unix)]
1190 fn expand_path_tilde_uses_home_unix() {
1191 let home = PathBuf::from(std::env::var("HOME").expect("HOME must be set on Unix"));
1200 let canonical_home = home.canonicalize().unwrap_or_else(|_| home.clone());
1201 let base = PathBuf::from("/irrelevant");
1202 let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
1203 assert!(result.is_absolute());
1204 assert!(
1205 result.starts_with(&home) || result.starts_with(&canonical_home),
1206 "expected path under HOME={home:?} (canonically {canonical_home:?}), got {result:?}"
1207 );
1208 assert!(result.to_string_lossy().contains("Documents/notes"));
1209 }
1210
1211 #[test]
1215 #[cfg(unix)]
1216 fn expand_path_tilde_expands_to_home_even_when_the_target_is_missing_unix() {
1217 let home = PathBuf::from(std::env::var("HOME").expect("HOME must be set on Unix"));
1218 let base = PathBuf::from("/irrelevant");
1219 let result = AppSettings::expand_path(
1220 std::path::Path::new("~/kimun-no-such-directory-2f8a1c/notes"),
1221 &base,
1222 );
1223 assert_eq!(result, home.join("kimun-no-such-directory-2f8a1c/notes"));
1224 }
1225
1226 #[test]
1227 #[cfg(unix)]
1228 fn expand_path_tilde_alone_is_home_unix() {
1229 let home = std::env::var("HOME").expect("HOME must be set on Unix");
1230 let base = PathBuf::from("/irrelevant");
1231 let result = AppSettings::expand_path(std::path::Path::new("~"), &base);
1232 assert!(result.is_absolute());
1233 let expected = PathBuf::from(&home)
1235 .canonicalize()
1236 .unwrap_or(PathBuf::from(&home));
1237 assert_eq!(result, expected);
1238 }
1239
1240 #[test]
1241 #[cfg(windows)]
1242 fn expand_path_tilde_uses_userprofile_windows() {
1243 let home = std::env::var("USERPROFILE").expect("USERPROFILE must be set on Windows");
1244 let base = PathBuf::from("C:\\irrelevant");
1245 let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
1246 assert!(result.is_absolute());
1247 assert!(
1248 result.starts_with(&home),
1249 "expected path to start with USERPROFILE={}, got {:?}",
1250 home,
1251 result
1252 );
1253 }
1254
1255 #[test]
1256 fn resolve_paths_populates_resolved_path() {
1257 let base = tempfile::TempDir::new().unwrap();
1258 let notes = base.path().join("notes");
1259 std::fs::create_dir_all(¬es).unwrap();
1260
1261 let toml = r#"
1262config_version = 2
1263[global]
1264current_workspace = "test"
1265[workspaces.test]
1266path = "notes"
1267last_paths = []
1268created = "2026-01-01T00:00:00Z"
1269"#
1270 .to_string();
1271 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1272 settings.resolve_paths(base.path());
1273
1274 let wc = settings.workspace_config.as_ref().unwrap();
1275 let entry = wc.workspaces.get("test").unwrap();
1276 assert_eq!(entry.path, PathBuf::from("notes"));
1278 assert!(entry.resolved_path.is_some());
1280 assert!(entry.effective_path().is_absolute());
1281 }
1282
1283 #[test]
1284 fn resolve_paths_absolute_no_resolved_path() {
1285 let toml = r#"
1286config_version = 2
1287[global]
1288current_workspace = "test"
1289[workspaces.test]
1290path = "/absolute/notes"
1291last_paths = []
1292created = "2026-01-01T00:00:00Z"
1293"#;
1294 let mut settings: AppSettings = toml::from_str(toml).unwrap();
1295 settings.resolve_paths(std::path::Path::new("/config"));
1296
1297 let wc = settings.workspace_config.as_ref().unwrap();
1298 let entry = wc.workspaces.get("test").unwrap();
1299 assert!(entry.resolved_path.is_none());
1301 assert_eq!(*entry.effective_path(), PathBuf::from("/absolute/notes"));
1302 }
1303}
1304
1305#[cfg(test)]
1306mod sort_settings_tests {
1307 use super::*;
1308
1309 #[test]
1310 fn group_directories_defaults_off() {
1311 let s = AppSettings::default();
1312 assert!(!s.group_directories);
1313 }
1314
1315 #[test]
1316 fn open_sort_dialog_is_bound_by_default() {
1317 let s = AppSettings::default();
1318 let map = s.key_bindings.to_hashmap();
1319 assert!(
1320 map.contains_key(&ActionShortcuts::OpenSortDialog),
1321 "OpenSortDialog must have a default binding"
1322 );
1323 }
1324}