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