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