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::F5, ActionShortcuts::SwitchWorkspace);
262
263 kb.batch_add()
268 .with_ctrl()
269 .add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
270
271 kb
272}
273
274fn yes() -> bool {
275 true
276}
277
278fn default_autosave_interval() -> u64 {
279 5
280}
281
282fn default_leader_timeout_ms() -> u64 {
283 400
284}
285
286#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
288pub struct LeaderConfig {
289 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
292 pub bind: std::collections::BTreeMap<String, String>,
293 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
296 pub labels: std::collections::BTreeMap<String, String>,
297}
298
299impl AppSettings {
300 pub fn default_workspace_suggestion() -> Option<PathBuf> {
303 config_dir::get_home_dir()
304 .ok()
305 .map(|h| h.join("kimun-notes"))
306 }
307
308 pub fn leader_tree(&self) -> crate::keys::leader::LeaderNode {
312 let tree = crate::keys::leader::apply_overrides(
313 crate::keys::leader::leader_tree(),
314 self.leader
315 .bind
316 .iter()
317 .map(|(k, v)| (k.as_str(), v.as_str())),
318 );
319 crate::keys::leader::apply_labels(
320 tree,
321 self.leader
322 .labels
323 .iter()
324 .map(|(k, v)| (k.as_str(), v.as_str())),
325 )
326 }
327}
328
329fn default_cache_dir() -> PathBuf {
330 PathBuf::from(".")
331}
332
333fn default_history_dir() -> PathBuf {
334 PathBuf::from("history")
335}
336
337fn default_use_nerd_fonts() -> bool {
338 false
339}
340
341fn default_sort_field() -> SortFieldSetting {
342 SortFieldSetting::Name
343}
344
345fn default_sort_order() -> SortOrderSetting {
346 SortOrderSetting::Ascending
347}
348
349fn default_journal_sort_field() -> SortFieldSetting {
350 SortFieldSetting::Name
351}
352
353fn default_journal_sort_order() -> SortOrderSetting {
354 SortOrderSetting::Descending
355}
356
357impl Default for AppSettings {
358 fn default() -> Self {
359 Self {
360 config_version: 0,
361 workspace_config: None,
362 last_paths: vec![],
363 workspace_dir: None,
364 theme: Default::default(),
365 cache_dir: default_cache_dir(),
366 cache_dir_resolved: None,
367 history_dir: default_history_dir(),
368 history_dir_resolved: None,
369 needs_indexing: true,
370 key_bindings: default_keybindings(),
371 autosave_interval_secs: default_autosave_interval(),
372 leader_timeout_ms: default_leader_timeout_ms(),
373 leader: LeaderConfig::default(),
374 use_nerd_fonts: false,
375 editor_backend: EditorBackendSetting::Textarea,
376 nvim_path: None,
377 default_sort_field: default_sort_field(),
378 default_sort_order: default_sort_order(),
379 journal_sort_field: default_journal_sort_field(),
380 journal_sort_order: default_journal_sort_order(),
381 group_directories: false,
382 config_file: None,
383 }
384 }
385}
386
387impl AppSettings {
388 pub fn theme_list(&self) -> Vec<Theme> {
389 let mut list = Theme::builtins();
390 list.append(&mut Self::load_custom_themes());
391 if let Ok(custom_default) = Self::load_default_theme() {
393 list.push(custom_default);
394 }
395 list.sort_by(|a, b| a.name.cmp(&b.name));
396 list
397 }
398
399 fn default_config_file_path() -> eyre::Result<PathBuf> {
400 let config_home = get_or_create_config_dir(CONFIG_DIR)?;
401 Ok(config_home.join(BASE_CONFIG_FILE))
402 }
403
404 fn get_config_file_path(&self) -> eyre::Result<PathBuf> {
405 if let Some(ref path) = self.config_file {
406 Ok(path.clone())
407 } else {
408 Self::default_config_file_path()
409 }
410 }
411
412 fn get_themes_path() -> eyre::Result<PathBuf> {
413 let config_home = get_or_create_config_dir(CONFIG_DIR)?;
414 Ok(config_home.join(THEMES_DIR))
415 }
416
417 fn load_theme_from_path(path: &std::path::Path) -> eyre::Result<Theme> {
418 let theme_string = fs::read_to_string(path)?;
419 match toml::from_str::<Theme>(&theme_string) {
420 Ok(theme) => Ok(theme),
421 Err(e) => {
422 tracing::warn!("Skipping unparsable theme file {:?}: {}", path, e);
425 Err(eyre::eyre!("corrupt theme file: {}", e))
426 }
427 }
428 }
429
430 fn load_default_theme() -> eyre::Result<Theme> {
431 let theme_path = AppSettings::get_themes_path()?.join("default.toml");
432 Self::load_theme_from_path(&theme_path)
433 }
434
435 fn load_custom_themes() -> Vec<Theme> {
436 let mut themes = Vec::new();
437
438 let themes_path = match Self::get_themes_path() {
440 Ok(path) => path,
441 Err(_) => return themes,
442 };
443
444 let entries = match fs::read_dir(&themes_path) {
446 Ok(entries) => entries,
447 Err(_) => return themes,
448 };
449
450 for entry in entries.flatten() {
452 let path = entry.path();
453
454 if !path.is_file() {
456 continue;
457 }
458
459 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
461 continue;
462 }
463
464 if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
466 continue;
467 }
468
469 match fs::read_to_string(&path)
471 .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
472 {
473 Ok(theme) => themes.push(theme),
474 Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
475 }
476 }
477
478 themes
479 }
480
481 pub fn update_check(&self) -> bool {
485 self.workspace_config
486 .as_ref()
487 .map(|wc| wc.global.update_check)
488 .unwrap_or(true)
489 }
490
491 pub fn mouse(&self) -> bool {
494 self.workspace_config
495 .as_ref()
496 .map(|wc| wc.global.mouse)
497 .unwrap_or(true)
498 }
499
500 pub fn save_to_disk(&self) -> eyre::Result<()> {
501 tracing::debug!("Saving settings to disk");
502 let settings_file_path = self.get_config_file_path()?;
503 let mut file = File::create(settings_file_path)?;
504 file.write_all(CONFIG_HEADER.as_bytes())?;
505 let toml = toml::to_string(&self)?;
506 file.write_all(toml.as_bytes())?;
507 Ok(())
508 }
509
510 pub fn load_from_disk() -> eyre::Result<Self> {
511 let settings_file_path = Self::default_config_file_path()?;
512
513 if !settings_file_path.exists() {
514 let default_settings = Self::default();
515 default_settings.save_to_disk()?;
516 Ok(default_settings)
517 } else {
518 let mut settings_file = File::open(&settings_file_path)?;
519
520 let mut toml = String::new();
521 settings_file.read_to_string(&mut toml)?;
522
523 match toml::from_str::<AppSettings>(toml.as_ref()) {
524 Ok(mut setting) => {
525 setting.config_file = Some(settings_file_path.clone());
526 let config_dir = settings_file_path
527 .parent()
528 .unwrap_or(std::path::Path::new("."));
529 setting.resolve_paths(config_dir);
530 if config_migration::ConfigMigration::run(&mut setting)? {
531 setting.save_to_disk()?;
532 }
533 setting.merge_missing_default_bindings();
534 Ok(setting)
535 }
536 Err(e) => {
537 tracing::warn!(
538 "Config file at {:?} could not be parsed ({}). \
539 Renaming to .corrupt and starting with defaults.",
540 settings_file_path,
541 e
542 );
543 let corrupt_path = settings_file_path.with_extension("toml.corrupt");
544 let _ = fs::rename(&settings_file_path, &corrupt_path);
545 let defaults = Self::default();
546 defaults.save_to_disk()?;
547 Ok(defaults)
548 }
549 }
550 }
551 }
552
553 pub fn load_from_file(path: PathBuf) -> eyre::Result<Self> {
554 if let Some(parent) = path.parent() {
555 fs::create_dir_all(parent)?;
556 }
557 if !path.exists() {
558 let default_settings = Self {
559 config_file: Some(path),
560 ..Self::default()
561 };
562 default_settings.save_to_disk()?;
563 return Ok(default_settings);
564 }
565 let mut toml_str = String::new();
566 File::open(&path)?.read_to_string(&mut toml_str)?;
567 match toml::from_str::<AppSettings>(&toml_str) {
568 Ok(mut setting) => {
569 setting.config_file = Some(path.clone());
570
571 let config_dir = path.parent().unwrap_or(std::path::Path::new("."));
573 setting.resolve_paths(config_dir);
574
575 if config_migration::ConfigMigration::run(&mut setting)? {
577 setting.save_to_disk()?;
578 }
579
580 setting.merge_missing_default_bindings();
581 Ok(setting)
582 }
583 Err(e) => {
584 tracing::warn!(
585 "Config file at {:?} could not be parsed ({}). \
586 Renaming to .corrupt and starting with defaults.",
587 path,
588 e
589 );
590 let corrupt_path = path.with_extension("toml.corrupt");
591 let _ = fs::rename(&path, &corrupt_path);
592 let defaults = Self {
593 config_file: Some(path),
594 ..Self::default()
595 };
596 defaults.save_to_disk()?;
597 Ok(defaults)
598 }
599 }
600 }
601
602 fn merge_missing_default_bindings(&mut self) {
608 let defaults = default_keybindings().to_hashmap();
609 let mut current = self.key_bindings.to_hashmap();
610 let mut bound: std::collections::HashSet<_> = current.values().flatten().cloned().collect();
611 for (action, combos) in defaults {
612 match current.entry(action) {
613 std::collections::hash_map::Entry::Vacant(e) => {
614 let free: Vec<_> = combos.into_iter().filter(|c| !bound.contains(c)).collect();
618 if !free.is_empty() {
619 bound.extend(free.iter().copied());
620 e.insert(free);
621 }
622 }
623 std::collections::hash_map::Entry::Occupied(mut e) => {
624 for combo in combos {
625 if !bound.contains(&combo) && !e.get().contains(&combo) {
626 bound.insert(combo);
627 e.get_mut().push(combo);
628 }
629 }
630 }
631 }
632 }
633 self.key_bindings = KeyBindings::from_hashmap(current);
634 }
635
636 pub fn set_workspace(&mut self, workspace_path: &PathBuf) {
639 if let Some(current_workspace_dir) = &self.workspace_dir
640 && workspace_path != current_workspace_dir
641 {
642 self.needs_indexing = true;
643 }
644
645 self.workspace_dir = Some(workspace_path.to_owned());
646 }
647
648 pub fn clear_workspace(&mut self) {
655 if self.workspace_dir.is_some() {
657 self.workspace_dir = None;
658 self.needs_indexing = true;
659 }
660 if let Some(wc) = &mut self.workspace_config {
662 let key = wc.global.current_workspace.clone();
663 if !key.is_empty() {
664 wc.workspaces.remove(&key);
665 }
666 wc.global.current_workspace = String::new();
667 }
668 }
669
670 pub fn resolve_workspace_path(&self) -> Option<PathBuf> {
673 self.workspace_config
674 .as_ref()
675 .and_then(|wc| wc.get_current_workspace())
676 .map(|entry| entry.effective_path().clone())
677 .or_else(|| self.workspace_dir.clone())
678 }
679
680 fn resolve_paths(&mut self, base: &std::path::Path) {
684 if let Some(ref mut p) = self.workspace_dir {
687 *p = Self::expand_path(p, base);
688 }
689 if let Some(ref mut wc) = self.workspace_config {
691 for entry in wc.workspaces.values_mut() {
692 let resolved = Self::expand_path(&entry.path, base);
693 if resolved != entry.path {
694 entry.resolved_path = Some(resolved);
695 }
696 }
697 }
698 self.cache_dir_resolved = Some(Self::expand_path(&self.cache_dir, base));
699 self.history_dir_resolved = Some(Self::expand_path(&self.history_dir, base));
700 }
701
702 fn expand_path(path: &std::path::Path, base: &std::path::Path) -> PathBuf {
706 let s = path.to_string_lossy();
707 let expanded = if s.starts_with("~/") || s == "~" {
708 if let Ok(home) = config_dir::get_home_dir() {
709 home.join(s.strip_prefix("~/").unwrap_or(""))
710 } else {
711 path.to_path_buf()
712 }
713 } else {
714 path.to_path_buf()
715 };
716 let absolute = if expanded.is_relative() {
717 base.join(expanded)
718 } else {
719 expanded
720 };
721 absolute.canonicalize().unwrap_or(absolute)
723 }
724
725 pub fn set_theme(&mut self, theme: String) {
726 self.theme = theme;
727 }
728
729 pub fn report_indexed(&mut self) {
730 self.needs_indexing = false;
731 }
732
733 pub fn needs_indexing(&self) -> bool {
734 self.needs_indexing
735 }
736
737 pub fn add_path_history(&mut self, note_path: &VaultPath) {
738 if !note_path.is_note() {
739 return;
740 }
741 let Some(workspace_name) = self.current_workspace_name() else {
742 return;
743 };
744 let file_path = self.history_path_for(&workspace_name);
745 if let Err(e) = history::push_history(&file_path, note_path) {
746 tracing::warn!("failed to write history {:?}: {}", file_path, e);
747 }
748 }
749
750 pub fn current_workspace_name(&self) -> Option<String> {
751 self.workspace_config
752 .as_ref()
753 .map(|wc| wc.global.current_workspace.clone())
754 .filter(|s| !s.is_empty())
755 }
756
757 pub fn cache_dir_resolved(&self) -> Option<&Path> {
758 self.cache_dir_resolved.as_deref()
759 }
760
761 pub fn history_dir_resolved(&self) -> Option<&Path> {
762 self.history_dir_resolved.as_deref()
763 }
764
765 pub fn cache_path_for(&self, workspace_name: &str) -> PathBuf {
769 Self::workspace_file(
770 self.cache_dir_resolved.as_ref().unwrap_or(&self.cache_dir),
771 workspace_name,
772 CACHE_FILE_EXT,
773 )
774 }
775
776 pub fn history_path_for(&self, workspace_name: &str) -> PathBuf {
779 Self::workspace_file(
780 self.history_dir_resolved
781 .as_ref()
782 .unwrap_or(&self.history_dir),
783 workspace_name,
784 HISTORY_FILE_EXT,
785 )
786 }
787
788 fn workspace_file(dir: &Path, workspace_name: &str, ext: &str) -> PathBuf {
789 dir.join(format!("{workspace_name}.{ext}"))
790 }
791
792 pub fn current_last_paths(&self) -> Vec<VaultPath> {
794 let Some(name) = self.current_workspace_name() else {
795 return Vec::new();
796 };
797 let file_path = self.history_path_for(&name);
798 history::load_history(&file_path)
799 }
800
801 pub fn icons(&self) -> icons::Icons {
803 icons::Icons::new(self.use_nerd_fonts)
804 }
805
806 pub fn effective_theme_name(&self) -> String {
810 if self.theme.is_empty() {
811 Theme::default().name
812 } else {
813 self.theme.clone()
814 }
815 }
816
817 pub fn get_theme(&self) -> Theme {
823 let theme = if self.theme.is_empty() {
824 Theme::default()
825 } else {
826 self.theme_list()
827 .into_iter()
828 .find(|t| t.name == self.theme)
829 .unwrap_or_default()
830 };
831 theme.adapt_to_terminal()
832 }
833}
834
835#[cfg(test)]
836#[allow(clippy::field_reassign_with_default)]
837mod tests {
838 use super::*;
839
840 #[test]
841 fn default_workspace_suggestion_is_under_home() {
842 let suggestion = AppSettings::default_workspace_suggestion();
843 if let Some(p) = suggestion {
844 assert!(p.ends_with("kimun-notes"));
845 assert!(p.is_absolute());
846 }
847 }
849
850 #[test]
851 fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
852 let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
855 let _ = std::fs::remove_file(&path); let result = AppSettings::load_theme_from_path(&path);
858
859 assert!(result.is_err(), "should return Err when file is absent");
860 assert!(!path.exists(), "must not create the file as a side effect");
861 }
862
863 #[test]
864 fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
865 let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
867 std::fs::write(&path, b"not valid toml {{{{").unwrap();
868
869 let result = AppSettings::load_theme_from_path(&path);
870
871 assert!(result.is_err(), "should return Err for corrupt TOML");
872 assert!(path.exists(), "corrupt theme file must not be deleted");
875 std::fs::remove_file(&path).ok();
876 }
877
878 #[test]
879 fn default_keybindings_quit_matches_canonical_combo() {
880 let kb = default_keybindings();
881 let combo = crate::keys::default_quit_combo();
882 assert_eq!(
883 kb.get_action(&combo),
884 Some(ActionShortcuts::Quit),
885 "default_keybindings() must bind default_quit_combo() to Quit so the \
886 deserialize safety net can recover an unreachable app"
887 );
888 }
889
890 #[test]
891 fn autosave_interval_defaults_to_five() {
892 let settings = AppSettings::default();
893 assert_eq!(settings.autosave_interval_secs, 5);
894 }
895
896 #[test]
897 fn autosave_interval_deserializes_from_toml() {
898 let toml = "autosave_interval_secs = 30\n";
899 let settings: AppSettings = toml::from_str(toml).unwrap();
900 assert_eq!(settings.autosave_interval_secs, 30);
901 }
902
903 #[test]
904 fn autosave_interval_defaults_when_missing_from_toml() {
905 let toml = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
907 assert_eq!(settings.autosave_interval_secs, 5);
908 }
909
910 #[test]
912 fn f2_file_operations_survives_toml_deserialize() {
913 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
914 use crate::keys::key_strike::KeyStrike;
915
916 let toml = r#"
917[key_bindings]
918FileOperations = ["F2"]
919"#;
920 let settings: AppSettings = toml::from_str(toml).unwrap();
921 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
922 let action = settings.key_bindings.get_action(&f2);
923 assert_eq!(
924 action,
925 Some(ActionShortcuts::FileOperations),
926 "F2 should survive deserialization and map to FileOperations"
927 );
928 }
929
930 #[test]
932 fn merge_adds_f2_when_absent() {
933 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
934 use crate::keys::key_strike::KeyStrike;
935
936 let toml = r#"
938[key_bindings]
939Quit = ["ctrl&Q"]
940"#;
941 let mut settings: AppSettings = toml::from_str(toml).unwrap();
942 settings.merge_missing_default_bindings();
943
944 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
945 let action = settings.key_bindings.get_action(&f2);
946 assert_eq!(
947 action,
948 Some(ActionShortcuts::FileOperations),
949 "merge_missing_default_bindings should add F2 → FileOperations"
950 );
951 }
952
953 #[test]
954 fn clear_workspace_phase1_clears_workspace_dir() {
955 let mut settings = AppSettings::default();
956 settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
957 settings.needs_indexing = false;
958 settings.clear_workspace();
959 assert!(
960 settings.workspace_dir.is_none(),
961 "workspace_dir should be None"
962 );
963 assert!(
964 settings.needs_indexing,
965 "needs_indexing should be reset to true"
966 );
967 }
968
969 #[test]
970 fn clear_workspace_phase2_removes_current_workspace_entry() {
971 let mut settings = AppSettings::default();
972 let mut wc = WorkspaceConfig::new_empty();
973 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
974 .unwrap();
975 settings.workspace_config = Some(wc);
976 assert_eq!(
978 settings
979 .workspace_config
980 .as_ref()
981 .unwrap()
982 .global
983 .current_workspace,
984 "vault1"
985 );
986 settings.clear_workspace();
987 let wc = settings.workspace_config.as_ref().unwrap();
988 assert!(
989 wc.workspaces.is_empty(),
990 "workspace entry should be removed"
991 );
992 assert!(
993 wc.global.current_workspace.is_empty(),
994 "current_workspace should be empty"
995 );
996 }
997
998 #[test]
999 fn clear_workspace_both_phases_active() {
1000 let mut settings = AppSettings::default();
1003 settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
1004 let mut wc = WorkspaceConfig::new_empty();
1005 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
1006 .unwrap();
1007 settings.workspace_config = Some(wc);
1008 settings.clear_workspace();
1009 assert!(
1010 settings.workspace_dir.is_none(),
1011 "phase1 workspace_dir should be cleared"
1012 );
1013 let wc = settings.workspace_config.as_ref().unwrap();
1014 assert!(
1015 wc.workspaces.is_empty(),
1016 "phase2 workspace entry should be removed"
1017 );
1018 assert!(
1019 wc.global.current_workspace.is_empty(),
1020 "phase2 current_workspace should be empty"
1021 );
1022 }
1023
1024 #[test]
1025 fn clear_workspace_phase2_preserves_other_workspaces() {
1026 let mut settings = AppSettings::default();
1027 let mut wc = WorkspaceConfig::new_empty();
1028 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
1029 .unwrap();
1030 wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
1031 .unwrap();
1032 wc.global.current_workspace = "vault1".to_string();
1033 settings.workspace_config = Some(wc);
1034 settings.clear_workspace();
1035 let wc = settings.workspace_config.as_ref().unwrap();
1036 assert!(
1037 !wc.workspaces.contains_key("vault1"),
1038 "active workspace should be removed"
1039 );
1040 assert!(
1041 wc.workspaces.contains_key("vault2"),
1042 "other workspaces should be preserved"
1043 );
1044 assert!(
1045 wc.global.current_workspace.is_empty(),
1046 "current_workspace should be empty"
1047 );
1048 }
1049}
1050
1051#[cfg(test)]
1052mod backend_tests {
1053 use super::*;
1054
1055 #[test]
1056 fn default_backend_is_textarea() {
1057 let settings = AppSettings::default();
1058 assert!(matches!(
1059 settings.editor_backend,
1060 EditorBackendSetting::Textarea
1061 ));
1062 }
1063
1064 #[test]
1065 fn nvim_backend_round_trips_toml() {
1066 let toml = "editor_backend = \"nvim\"\n";
1067 let parsed: AppSettings = toml::from_str(toml).unwrap();
1068 assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
1069 }
1070
1071 #[test]
1072 fn editor_backend_vim_roundtrips_through_toml() {
1073 #[derive(serde::Serialize, serde::Deserialize)]
1074 struct W {
1075 editor_backend: EditorBackendSetting,
1076 }
1077 let w = W {
1078 editor_backend: EditorBackendSetting::Vim,
1079 };
1080 let s = toml::to_string(&w).unwrap();
1081 assert!(s.contains("editor_backend = \"vim\""), "serialized: {s}");
1082 let back: W = toml::from_str(&s).unwrap();
1083 assert_eq!(back.editor_backend, EditorBackendSetting::Vim);
1084 }
1085
1086 #[test]
1089 fn expand_path_absolute_unchanged() {
1090 let base = PathBuf::from("/config/dir");
1091 let result = AppSettings::expand_path(std::path::Path::new("/absolute/path/notes"), &base);
1092 assert!(result.is_absolute());
1093 assert!(result.to_string_lossy().contains("absolute"));
1094 }
1095
1096 #[test]
1097 fn expand_path_relative_resolved_against_base() {
1098 let base = tempfile::TempDir::new().unwrap();
1099 let notes = base.path().join("notes");
1100 std::fs::create_dir_all(¬es).unwrap();
1101
1102 let result = AppSettings::expand_path(std::path::Path::new("notes"), base.path());
1103 assert!(result.is_absolute());
1104 assert_eq!(result, notes.canonicalize().unwrap());
1105 }
1106
1107 #[test]
1108 fn expand_path_relative_with_dotdot() {
1109 let base = tempfile::TempDir::new().unwrap();
1110 let sibling = base.path().join("sibling");
1111 std::fs::create_dir_all(&sibling).unwrap();
1112 let sub = base.path().join("sub");
1113 std::fs::create_dir_all(&sub).unwrap();
1114
1115 let result = AppSettings::expand_path(std::path::Path::new("../sibling"), &sub);
1116 assert!(result.is_absolute());
1117 assert_eq!(result, sibling.canonicalize().unwrap());
1118 }
1119
1120 #[test]
1121 fn expand_path_nonexistent_relative_still_absolute() {
1122 let base = PathBuf::from("/some/config/dir");
1123 let result = AppSettings::expand_path(std::path::Path::new("my-notes"), &base);
1124 assert!(result.is_absolute());
1125 assert_eq!(result, PathBuf::from("/some/config/dir/my-notes"));
1126 }
1127
1128 #[test]
1129 #[cfg(unix)]
1130 fn expand_path_tilde_uses_home_unix() {
1131 let home = std::env::var("HOME").expect("HOME must be set on Unix");
1132 let base = PathBuf::from("/irrelevant");
1133 let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
1134 assert!(result.is_absolute());
1135 assert!(
1136 result.starts_with(&home),
1137 "expected path to start with HOME={}, got {:?}",
1138 home,
1139 result
1140 );
1141 assert!(result.to_string_lossy().contains("Documents/notes"));
1142 }
1143
1144 #[test]
1145 #[cfg(unix)]
1146 fn expand_path_tilde_alone_is_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("~"), &base);
1150 assert!(result.is_absolute());
1151 let expected = PathBuf::from(&home)
1153 .canonicalize()
1154 .unwrap_or(PathBuf::from(&home));
1155 assert_eq!(result, expected);
1156 }
1157
1158 #[test]
1159 #[cfg(windows)]
1160 fn expand_path_tilde_uses_userprofile_windows() {
1161 let home = std::env::var("USERPROFILE").expect("USERPROFILE must be set on Windows");
1162 let base = PathBuf::from("C:\\irrelevant");
1163 let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
1164 assert!(result.is_absolute());
1165 assert!(
1166 result.starts_with(&home),
1167 "expected path to start with USERPROFILE={}, got {:?}",
1168 home,
1169 result
1170 );
1171 }
1172
1173 #[test]
1174 fn resolve_paths_populates_resolved_path() {
1175 let base = tempfile::TempDir::new().unwrap();
1176 let notes = base.path().join("notes");
1177 std::fs::create_dir_all(¬es).unwrap();
1178
1179 let toml = r#"
1180config_version = 2
1181[global]
1182current_workspace = "test"
1183[workspaces.test]
1184path = "notes"
1185last_paths = []
1186created = "2026-01-01T00:00:00Z"
1187"#
1188 .to_string();
1189 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1190 settings.resolve_paths(base.path());
1191
1192 let wc = settings.workspace_config.as_ref().unwrap();
1193 let entry = wc.workspaces.get("test").unwrap();
1194 assert_eq!(entry.path, PathBuf::from("notes"));
1196 assert!(entry.resolved_path.is_some());
1198 assert!(entry.effective_path().is_absolute());
1199 }
1200
1201 #[test]
1202 fn resolve_paths_absolute_no_resolved_path() {
1203 let toml = r#"
1204config_version = 2
1205[global]
1206current_workspace = "test"
1207[workspaces.test]
1208path = "/absolute/notes"
1209last_paths = []
1210created = "2026-01-01T00:00:00Z"
1211"#;
1212 let mut settings: AppSettings = toml::from_str(toml).unwrap();
1213 settings.resolve_paths(std::path::Path::new("/config"));
1214
1215 let wc = settings.workspace_config.as_ref().unwrap();
1216 let entry = wc.workspaces.get("test").unwrap();
1217 assert!(entry.resolved_path.is_none());
1219 assert_eq!(*entry.effective_path(), PathBuf::from("/absolute/notes"));
1220 }
1221}
1222
1223#[cfg(test)]
1224mod sort_settings_tests {
1225 use super::*;
1226
1227 #[test]
1228 fn group_directories_defaults_off() {
1229 let s = AppSettings::default();
1230 assert!(!s.group_directories);
1231 }
1232
1233 #[test]
1234 fn open_sort_dialog_is_bound_by_default() {
1235 let s = AppSettings::default();
1236 let map = s.key_bindings.to_hashmap();
1237 assert!(
1238 map.contains_key(&ActionShortcuts::OpenSortDialog),
1239 "OpenSortDialog must have a default binding"
1240 );
1241 }
1242}