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, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum EditorBackendSetting {
47 #[default]
48 Textarea,
49 Nvim,
50}
51
52#[cfg(debug_assertions)]
55const CONFIG_DIR: &str = "kimun_debug";
56#[cfg(not(debug_assertions))]
57const CONFIG_DIR: &str = "kimun";
58
59const BASE_CONFIG_FILE: &str = "config.toml";
60const THEMES_DIR: &str = "themes";
61const CACHE_FILE_EXT: &str = "kimuncache";
62const HISTORY_FILE_EXT: &str = "txt";
63
64const CONFIG_HEADER: &str = "\
65# ─── Kimün configuration ────────────────────────────────────────────────────
66#
67# KEY BINDINGS
68# ────────────
69# Supported combinations:
70# - ctrl and/or alt (with optional shift) + a letter (a-z)
71# - bare F-key (F1–F12, no modifier required)
72# Any combo that does not follow these rules is silently ignored when loaded.
73#
74# Format per action:
75# ActionName = [\"<modifiers> & <letter>\", ...]
76#
77# Available modifiers (combine with +): ctrl alt shift
78#
79# Examples:
80# Quit = [\"ctrl&Q\"] # Ctrl+Q
81# SearchNotes = [\"ctrl&K\"] # Ctrl+K
82# OpenNote = [\"ctrl&O\"] # Ctrl+O (fuzzy file finder)
83# OpenSettings = [\"ctrl+shift&P\"] # Ctrl+Shift+P
84# NewJournal = [\"ctrl&J\"] # Ctrl+J
85# FileOperations = [\"F2\"] # F2 (open file-ops menu: delete/rename/move)
86#
87# ─────────────────────────────────────────────────────────────────────────────
88";
89
90#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
91pub struct AppSettings {
92 #[serde(default)]
94 pub config_version: u32,
95 #[serde(flatten, skip_serializing_if = "Option::is_none")]
96 pub workspace_config: Option<WorkspaceConfig>,
97
98 #[serde(skip_serializing_if = "Option::is_none")]
102 pub workspace_dir: Option<PathBuf>,
103 #[serde(default, skip_serializing)]
104 pub last_paths: Vec<VaultPath>,
105
106 #[serde(default)]
108 pub theme: String,
109 #[serde(default = "default_cache_dir")]
110 pub cache_dir: PathBuf,
111 #[serde(skip)]
112 cache_dir_resolved: Option<PathBuf>,
113
114 #[serde(default = "default_history_dir")]
115 pub history_dir: PathBuf,
116 #[serde(skip)]
117 history_dir_resolved: Option<PathBuf>,
118 #[serde(skip, default = "yes")]
119 needs_indexing: bool,
120 #[serde(default = "default_keybindings")]
121 pub key_bindings: KeyBindings,
122 #[serde(default = "default_autosave_interval")]
123 pub autosave_interval_secs: u64,
124 #[serde(default = "default_use_nerd_fonts")]
125 pub use_nerd_fonts: bool,
126 #[serde(default)]
127 pub editor_backend: EditorBackendSetting,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub nvim_path: Option<std::path::PathBuf>,
130 #[serde(default = "default_sort_field")]
131 pub default_sort_field: SortFieldSetting,
132 #[serde(default = "default_sort_order")]
133 pub default_sort_order: SortOrderSetting,
134 #[serde(default = "default_journal_sort_field")]
135 pub journal_sort_field: SortFieldSetting,
136 #[serde(default = "default_journal_sort_order")]
137 pub journal_sort_order: SortOrderSetting,
138 #[serde(default)]
139 pub group_directories: bool,
140 #[serde(skip)]
143 pub config_file: Option<PathBuf>,
144}
145
146fn default_keybindings() -> KeyBindings {
147 let mut kb = KeyBindings::empty();
148 kb.batch_add()
149 .with_ctrl()
150 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
151 .add(KeyStrike::KeyO, ActionShortcuts::OpenNote)
152 .add(KeyStrike::KeyY, ActionShortcuts::TogglePreview)
153 .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
154 .add(KeyStrike::KeyI, ActionShortcuts::Text(TextAction::Italic))
155 .add(
156 KeyStrike::KeyU,
157 ActionShortcuts::Text(TextAction::Underline),
158 )
159 .add(
160 KeyStrike::KeyS,
161 ActionShortcuts::Text(TextAction::Strikethrough),
162 )
163 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Link))
164 .add(
165 KeyStrike::KeyT,
166 ActionShortcuts::Text(TextAction::ToggleHeader),
167 )
168 .with_shift()
172 .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
173
174 kb.batch_add()
178 .with_ctrl()
179 .add(KeyStrike::KeyP, ActionShortcuts::OpenSettings)
180 .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
181 .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
182 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
183 .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
184 .add(KeyStrike::KeyG, ActionShortcuts::FollowLink)
185 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
186 .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
187 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
188 .add(KeyStrike::KeyE, ActionShortcuts::ToggleQueryPanel)
189 .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer);
190
191 kb.batch_add()
193 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
194
195 kb.batch_add()
196 .add(KeyStrike::F3, ActionShortcuts::OpenSavedSearches);
197
198 kb.batch_add()
199 .add(KeyStrike::F4, ActionShortcuts::SwitchWorkspace);
200
201 kb.batch_add()
206 .with_ctrl()
207 .add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
208
209 kb
210}
211
212fn yes() -> bool {
213 true
214}
215
216fn default_autosave_interval() -> u64 {
217 5
218}
219
220fn default_cache_dir() -> PathBuf {
221 PathBuf::from(".")
222}
223
224fn default_history_dir() -> PathBuf {
225 PathBuf::from("history")
226}
227
228fn default_use_nerd_fonts() -> bool {
229 false
230}
231
232fn default_sort_field() -> SortFieldSetting {
233 SortFieldSetting::Name
234}
235
236fn default_sort_order() -> SortOrderSetting {
237 SortOrderSetting::Ascending
238}
239
240fn default_journal_sort_field() -> SortFieldSetting {
241 SortFieldSetting::Name
242}
243
244fn default_journal_sort_order() -> SortOrderSetting {
245 SortOrderSetting::Descending
246}
247
248impl Default for AppSettings {
249 fn default() -> Self {
250 Self {
251 config_version: 0,
252 workspace_config: None,
253 last_paths: vec![],
254 workspace_dir: None,
255 theme: Default::default(),
256 cache_dir: default_cache_dir(),
257 cache_dir_resolved: None,
258 history_dir: default_history_dir(),
259 history_dir_resolved: None,
260 needs_indexing: true,
261 key_bindings: default_keybindings(),
262 autosave_interval_secs: default_autosave_interval(),
263 use_nerd_fonts: false,
264 editor_backend: EditorBackendSetting::Textarea,
265 nvim_path: None,
266 default_sort_field: default_sort_field(),
267 default_sort_order: default_sort_order(),
268 journal_sort_field: default_journal_sort_field(),
269 journal_sort_order: default_journal_sort_order(),
270 group_directories: false,
271 config_file: None,
272 }
273 }
274}
275
276impl AppSettings {
277 pub fn theme_list(&self) -> Vec<Theme> {
278 let mut list = vec![
279 Theme::gruvbox_dark(),
280 Theme::gruvbox_light(),
281 Theme::catppuccin_mocha(),
282 Theme::catppuccin_latte(),
283 Theme::tokyo_night(),
284 Theme::tokyo_night_storm(),
285 Theme::solarized_dark(),
286 Theme::solarized_light(),
287 Theme::nord(),
288 Theme::dracula(),
289 Theme::alucard(),
290 Theme::one_dark(),
291 Theme::one_light(),
292 Theme::monokai(),
293 Theme::everforest_dark(),
294 Theme::everforest_light(),
295 Theme::rose_pine(),
296 Theme::rose_pine_dawn(),
297 Theme::kanagawa_wave(),
298 Theme::kanagawa_lotus(),
299 Theme::ansi(),
300 ];
301 list.append(&mut Self::load_custom_themes());
302 if let Ok(custom_default) = Self::load_default_theme() {
304 list.push(custom_default);
305 }
306 list.sort_by(|a, b| a.name.cmp(&b.name));
307 list
308 }
309
310 fn default_config_file_path() -> eyre::Result<PathBuf> {
311 let config_home = get_or_create_config_dir(CONFIG_DIR)?;
312 Ok(config_home.join(BASE_CONFIG_FILE))
313 }
314
315 fn get_config_file_path(&self) -> eyre::Result<PathBuf> {
316 if let Some(ref path) = self.config_file {
317 Ok(path.clone())
318 } else {
319 Self::default_config_file_path()
320 }
321 }
322
323 fn get_themes_path() -> eyre::Result<PathBuf> {
324 let config_home = get_or_create_config_dir(CONFIG_DIR)?;
325 Ok(config_home.join(THEMES_DIR))
326 }
327
328 fn load_theme_from_path(path: &std::path::Path) -> eyre::Result<Theme> {
329 let theme_string = fs::read_to_string(path)?;
330 match toml::from_str::<Theme>(&theme_string) {
331 Ok(theme) => Ok(theme),
332 Err(e) => {
333 tracing::debug!(
334 "Failed to deserialize theme file {:?}: {}. Removing.",
335 path,
336 e
337 );
338 let _ = fs::remove_file(path);
339 Err(eyre::eyre!("corrupt theme file: {}", e))
340 }
341 }
342 }
343
344 fn load_default_theme() -> eyre::Result<Theme> {
345 let theme_path = AppSettings::get_themes_path()?.join("default.toml");
346 Self::load_theme_from_path(&theme_path)
347 }
348
349 fn load_custom_themes() -> Vec<Theme> {
350 let mut themes = Vec::new();
351
352 let themes_path = match Self::get_themes_path() {
354 Ok(path) => path,
355 Err(_) => return themes,
356 };
357
358 let entries = match fs::read_dir(&themes_path) {
360 Ok(entries) => entries,
361 Err(_) => return themes,
362 };
363
364 for entry in entries.flatten() {
366 let path = entry.path();
367
368 if !path.is_file() {
370 continue;
371 }
372
373 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
375 continue;
376 }
377
378 if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
380 continue;
381 }
382
383 match fs::read_to_string(&path)
385 .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
386 {
387 Ok(theme) => themes.push(theme),
388 Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
389 }
390 }
391
392 themes
393 }
394
395 pub fn save_to_disk(&self) -> eyre::Result<()> {
396 tracing::debug!("Saving settings to disk");
397 let settings_file_path = self.get_config_file_path()?;
398 let mut file = File::create(settings_file_path)?;
399 file.write_all(CONFIG_HEADER.as_bytes())?;
400 let toml = toml::to_string(&self)?;
401 file.write_all(toml.as_bytes())?;
402 Ok(())
403 }
404
405 pub fn load_from_disk() -> eyre::Result<Self> {
406 let settings_file_path = Self::default_config_file_path()?;
407
408 if !settings_file_path.exists() {
409 let default_settings = Self::default();
410 default_settings.save_to_disk()?;
411 Ok(default_settings)
412 } else {
413 let mut settings_file = File::open(&settings_file_path)?;
414
415 let mut toml = String::new();
416 settings_file.read_to_string(&mut toml)?;
417
418 match toml::from_str::<AppSettings>(toml.as_ref()) {
419 Ok(mut setting) => {
420 setting.config_file = Some(settings_file_path.clone());
421 let config_dir = settings_file_path
422 .parent()
423 .unwrap_or(std::path::Path::new("."));
424 setting.resolve_paths(config_dir);
425 if config_migration::ConfigMigration::run(&mut setting)? {
426 setting.save_to_disk()?;
427 }
428 setting.merge_missing_default_bindings();
429 Ok(setting)
430 }
431 Err(e) => {
432 tracing::warn!(
433 "Config file at {:?} could not be parsed ({}). \
434 Renaming to .corrupt and starting with defaults.",
435 settings_file_path,
436 e
437 );
438 let corrupt_path = settings_file_path.with_extension("toml.corrupt");
439 let _ = fs::rename(&settings_file_path, &corrupt_path);
440 let defaults = Self::default();
441 defaults.save_to_disk()?;
442 Ok(defaults)
443 }
444 }
445 }
446 }
447
448 pub fn load_from_file(path: PathBuf) -> eyre::Result<Self> {
449 if let Some(parent) = path.parent() {
450 fs::create_dir_all(parent)?;
451 }
452 if !path.exists() {
453 let default_settings = Self {
454 config_file: Some(path),
455 ..Self::default()
456 };
457 default_settings.save_to_disk()?;
458 return Ok(default_settings);
459 }
460 let mut toml_str = String::new();
461 File::open(&path)?.read_to_string(&mut toml_str)?;
462 match toml::from_str::<AppSettings>(&toml_str) {
463 Ok(mut setting) => {
464 setting.config_file = Some(path.clone());
465
466 let config_dir = path.parent().unwrap_or(std::path::Path::new("."));
468 setting.resolve_paths(config_dir);
469
470 if config_migration::ConfigMigration::run(&mut setting)? {
472 setting.save_to_disk()?;
473 }
474
475 setting.merge_missing_default_bindings();
476 Ok(setting)
477 }
478 Err(e) => {
479 tracing::warn!(
480 "Config file at {:?} could not be parsed ({}). \
481 Renaming to .corrupt and starting with defaults.",
482 path,
483 e
484 );
485 let corrupt_path = path.with_extension("toml.corrupt");
486 let _ = fs::rename(&path, &corrupt_path);
487 let defaults = Self {
488 config_file: Some(path),
489 ..Self::default()
490 };
491 defaults.save_to_disk()?;
492 Ok(defaults)
493 }
494 }
495 }
496
497 fn merge_missing_default_bindings(&mut self) {
500 let defaults = default_keybindings().to_hashmap();
501 let mut current = self.key_bindings.to_hashmap();
502 for (action, combos) in defaults {
503 current.entry(action).or_insert(combos);
504 }
505 self.key_bindings = KeyBindings::from_hashmap(current);
506 }
507
508 pub fn set_workspace(&mut self, workspace_path: &PathBuf) {
511 if let Some(current_workspace_dir) = &self.workspace_dir
512 && workspace_path != current_workspace_dir
513 {
514 self.needs_indexing = true;
515 }
516
517 self.workspace_dir = Some(workspace_path.to_owned());
518 }
519
520 pub fn clear_workspace(&mut self) {
527 if self.workspace_dir.is_some() {
529 self.workspace_dir = None;
530 self.needs_indexing = true;
531 }
532 if let Some(wc) = &mut self.workspace_config {
534 let key = wc.global.current_workspace.clone();
535 if !key.is_empty() {
536 wc.workspaces.remove(&key);
537 }
538 wc.global.current_workspace = String::new();
539 }
540 }
541
542 pub fn resolve_workspace_path(&self) -> Option<PathBuf> {
545 self.workspace_config
546 .as_ref()
547 .and_then(|wc| wc.get_current_workspace())
548 .map(|entry| entry.effective_path().clone())
549 .or_else(|| self.workspace_dir.clone())
550 }
551
552 fn resolve_paths(&mut self, base: &std::path::Path) {
556 if let Some(ref mut p) = self.workspace_dir {
559 *p = Self::expand_path(p, base);
560 }
561 if let Some(ref mut wc) = self.workspace_config {
563 for entry in wc.workspaces.values_mut() {
564 let resolved = Self::expand_path(&entry.path, base);
565 if resolved != entry.path {
566 entry.resolved_path = Some(resolved);
567 }
568 }
569 }
570 self.cache_dir_resolved = Some(Self::expand_path(&self.cache_dir, base));
571 self.history_dir_resolved = Some(Self::expand_path(&self.history_dir, base));
572 }
573
574 fn expand_path(path: &std::path::Path, base: &std::path::Path) -> PathBuf {
578 let s = path.to_string_lossy();
579 let expanded = if s.starts_with("~/") || s == "~" {
580 if let Ok(home) = config_dir::get_home_dir() {
581 home.join(s.strip_prefix("~/").unwrap_or(""))
582 } else {
583 path.to_path_buf()
584 }
585 } else {
586 path.to_path_buf()
587 };
588 let absolute = if expanded.is_relative() {
589 base.join(expanded)
590 } else {
591 expanded
592 };
593 absolute.canonicalize().unwrap_or(absolute)
595 }
596
597 pub fn set_theme(&mut self, theme: String) {
598 self.theme = theme;
599 }
600
601 pub fn report_indexed(&mut self) {
602 self.needs_indexing = false;
603 }
604
605 pub fn needs_indexing(&self) -> bool {
606 self.needs_indexing
607 }
608
609 pub fn add_path_history(&mut self, note_path: &VaultPath) {
610 if !note_path.is_note() {
611 return;
612 }
613 let Some(workspace_name) = self.current_workspace_name() else {
614 return;
615 };
616 let file_path = self.history_path_for(&workspace_name);
617 if let Err(e) = history::push_history(&file_path, note_path) {
618 tracing::warn!("failed to write history {:?}: {}", file_path, e);
619 }
620 }
621
622 pub fn current_workspace_name(&self) -> Option<String> {
623 self.workspace_config
624 .as_ref()
625 .map(|wc| wc.global.current_workspace.clone())
626 .filter(|s| !s.is_empty())
627 }
628
629 pub fn cache_dir_resolved(&self) -> Option<&Path> {
630 self.cache_dir_resolved.as_deref()
631 }
632
633 pub fn history_dir_resolved(&self) -> Option<&Path> {
634 self.history_dir_resolved.as_deref()
635 }
636
637 pub fn cache_path_for(&self, workspace_name: &str) -> PathBuf {
641 Self::workspace_file(
642 self.cache_dir_resolved.as_ref().unwrap_or(&self.cache_dir),
643 workspace_name,
644 CACHE_FILE_EXT,
645 )
646 }
647
648 pub fn history_path_for(&self, workspace_name: &str) -> PathBuf {
651 Self::workspace_file(
652 self.history_dir_resolved
653 .as_ref()
654 .unwrap_or(&self.history_dir),
655 workspace_name,
656 HISTORY_FILE_EXT,
657 )
658 }
659
660 fn workspace_file(dir: &Path, workspace_name: &str, ext: &str) -> PathBuf {
661 dir.join(format!("{workspace_name}.{ext}"))
662 }
663
664 pub fn current_last_paths(&self) -> Vec<VaultPath> {
666 let Some(name) = self.current_workspace_name() else {
667 return Vec::new();
668 };
669 let file_path = self.history_path_for(&name);
670 history::load_history(&file_path)
671 }
672
673 pub fn icons(&self) -> icons::Icons {
675 icons::Icons::new(self.use_nerd_fonts)
676 }
677
678 pub fn get_theme(&self) -> Theme {
680 if self.theme.is_empty() {
681 return Theme::default();
682 }
683 self.theme_list()
684 .into_iter()
685 .find(|t| t.name == self.theme)
686 .unwrap_or_default()
687 }
688}
689
690#[cfg(test)]
691#[allow(clippy::field_reassign_with_default)]
692mod tests {
693 use super::*;
694
695 #[test]
696 fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
697 let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
700 let _ = std::fs::remove_file(&path); let result = AppSettings::load_theme_from_path(&path);
703
704 assert!(result.is_err(), "should return Err when file is absent");
705 assert!(!path.exists(), "must not create the file as a side effect");
706 }
707
708 #[test]
709 fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
710 let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
712 std::fs::write(&path, b"not valid toml {{{{").unwrap();
713
714 let result = AppSettings::load_theme_from_path(&path);
715
716 assert!(result.is_err(), "should return Err for corrupt TOML");
717 assert!(
718 !path.exists(),
719 "corrupt file must be removed, not recreated"
720 );
721 }
722
723 #[test]
724 fn default_keybindings_quit_matches_canonical_combo() {
725 let kb = default_keybindings();
726 let combo = crate::keys::default_quit_combo();
727 assert_eq!(
728 kb.get_action(&combo),
729 Some(ActionShortcuts::Quit),
730 "default_keybindings() must bind default_quit_combo() to Quit so the \
731 deserialize safety net can recover an unreachable app"
732 );
733 }
734
735 #[test]
736 fn autosave_interval_defaults_to_five() {
737 let settings = AppSettings::default();
738 assert_eq!(settings.autosave_interval_secs, 5);
739 }
740
741 #[test]
742 fn autosave_interval_deserializes_from_toml() {
743 let toml = "autosave_interval_secs = 30\n";
744 let settings: AppSettings = toml::from_str(toml).unwrap();
745 assert_eq!(settings.autosave_interval_secs, 30);
746 }
747
748 #[test]
749 fn autosave_interval_defaults_when_missing_from_toml() {
750 let toml = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
752 assert_eq!(settings.autosave_interval_secs, 5);
753 }
754
755 #[test]
757 fn f2_file_operations_survives_toml_deserialize() {
758 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
759 use crate::keys::key_strike::KeyStrike;
760
761 let toml = r#"
762[key_bindings]
763FileOperations = ["F2"]
764"#;
765 let settings: AppSettings = toml::from_str(toml).unwrap();
766 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
767 let action = settings.key_bindings.get_action(&f2);
768 assert_eq!(
769 action,
770 Some(ActionShortcuts::FileOperations),
771 "F2 should survive deserialization and map to FileOperations"
772 );
773 }
774
775 #[test]
777 fn merge_adds_f2_when_absent() {
778 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
779 use crate::keys::key_strike::KeyStrike;
780
781 let toml = r#"
783[key_bindings]
784Quit = ["ctrl&Q"]
785"#;
786 let mut settings: AppSettings = toml::from_str(toml).unwrap();
787 settings.merge_missing_default_bindings();
788
789 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
790 let action = settings.key_bindings.get_action(&f2);
791 assert_eq!(
792 action,
793 Some(ActionShortcuts::FileOperations),
794 "merge_missing_default_bindings should add F2 → FileOperations"
795 );
796 }
797
798 #[test]
799 fn clear_workspace_phase1_clears_workspace_dir() {
800 let mut settings = AppSettings::default();
801 settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
802 settings.needs_indexing = false;
803 settings.clear_workspace();
804 assert!(
805 settings.workspace_dir.is_none(),
806 "workspace_dir should be None"
807 );
808 assert!(
809 settings.needs_indexing,
810 "needs_indexing should be reset to true"
811 );
812 }
813
814 #[test]
815 fn clear_workspace_phase2_removes_current_workspace_entry() {
816 let mut settings = AppSettings::default();
817 let mut wc = WorkspaceConfig::new_empty();
818 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
819 .unwrap();
820 settings.workspace_config = Some(wc);
821 assert_eq!(
823 settings
824 .workspace_config
825 .as_ref()
826 .unwrap()
827 .global
828 .current_workspace,
829 "vault1"
830 );
831 settings.clear_workspace();
832 let wc = settings.workspace_config.as_ref().unwrap();
833 assert!(
834 wc.workspaces.is_empty(),
835 "workspace entry should be removed"
836 );
837 assert!(
838 wc.global.current_workspace.is_empty(),
839 "current_workspace should be empty"
840 );
841 }
842
843 #[test]
844 fn clear_workspace_both_phases_active() {
845 let mut settings = AppSettings::default();
848 settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
849 let mut wc = WorkspaceConfig::new_empty();
850 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
851 .unwrap();
852 settings.workspace_config = Some(wc);
853 settings.clear_workspace();
854 assert!(
855 settings.workspace_dir.is_none(),
856 "phase1 workspace_dir should be cleared"
857 );
858 let wc = settings.workspace_config.as_ref().unwrap();
859 assert!(
860 wc.workspaces.is_empty(),
861 "phase2 workspace entry should be removed"
862 );
863 assert!(
864 wc.global.current_workspace.is_empty(),
865 "phase2 current_workspace should be empty"
866 );
867 }
868
869 #[test]
870 fn clear_workspace_phase2_preserves_other_workspaces() {
871 let mut settings = AppSettings::default();
872 let mut wc = WorkspaceConfig::new_empty();
873 wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
874 .unwrap();
875 wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
876 .unwrap();
877 wc.global.current_workspace = "vault1".to_string();
878 settings.workspace_config = Some(wc);
879 settings.clear_workspace();
880 let wc = settings.workspace_config.as_ref().unwrap();
881 assert!(
882 !wc.workspaces.contains_key("vault1"),
883 "active workspace should be removed"
884 );
885 assert!(
886 wc.workspaces.contains_key("vault2"),
887 "other workspaces should be preserved"
888 );
889 assert!(
890 wc.global.current_workspace.is_empty(),
891 "current_workspace should be empty"
892 );
893 }
894}
895
896#[cfg(test)]
897mod backend_tests {
898 use super::*;
899
900 #[test]
901 fn default_backend_is_textarea() {
902 let settings = AppSettings::default();
903 assert!(matches!(
904 settings.editor_backend,
905 EditorBackendSetting::Textarea
906 ));
907 }
908
909 #[test]
910 fn nvim_backend_round_trips_toml() {
911 let toml = "editor_backend = \"nvim\"\n";
912 let parsed: AppSettings = toml::from_str(toml).unwrap();
913 assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
914 }
915
916 #[test]
919 fn expand_path_absolute_unchanged() {
920 let base = PathBuf::from("/config/dir");
921 let result = AppSettings::expand_path(std::path::Path::new("/absolute/path/notes"), &base);
922 assert!(result.is_absolute());
923 assert!(result.to_string_lossy().contains("absolute"));
924 }
925
926 #[test]
927 fn expand_path_relative_resolved_against_base() {
928 let base = tempfile::TempDir::new().unwrap();
929 let notes = base.path().join("notes");
930 std::fs::create_dir_all(¬es).unwrap();
931
932 let result = AppSettings::expand_path(std::path::Path::new("notes"), base.path());
933 assert!(result.is_absolute());
934 assert_eq!(result, notes.canonicalize().unwrap());
935 }
936
937 #[test]
938 fn expand_path_relative_with_dotdot() {
939 let base = tempfile::TempDir::new().unwrap();
940 let sibling = base.path().join("sibling");
941 std::fs::create_dir_all(&sibling).unwrap();
942 let sub = base.path().join("sub");
943 std::fs::create_dir_all(&sub).unwrap();
944
945 let result = AppSettings::expand_path(std::path::Path::new("../sibling"), &sub);
946 assert!(result.is_absolute());
947 assert_eq!(result, sibling.canonicalize().unwrap());
948 }
949
950 #[test]
951 fn expand_path_nonexistent_relative_still_absolute() {
952 let base = PathBuf::from("/some/config/dir");
953 let result = AppSettings::expand_path(std::path::Path::new("my-notes"), &base);
954 assert!(result.is_absolute());
955 assert_eq!(result, PathBuf::from("/some/config/dir/my-notes"));
956 }
957
958 #[test]
959 #[cfg(unix)]
960 fn expand_path_tilde_uses_home_unix() {
961 let home = std::env::var("HOME").expect("HOME must be set on Unix");
962 let base = PathBuf::from("/irrelevant");
963 let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
964 assert!(result.is_absolute());
965 assert!(
966 result.starts_with(&home),
967 "expected path to start with HOME={}, got {:?}",
968 home,
969 result
970 );
971 assert!(result.to_string_lossy().contains("Documents/notes"));
972 }
973
974 #[test]
975 #[cfg(unix)]
976 fn expand_path_tilde_alone_is_home_unix() {
977 let home = std::env::var("HOME").expect("HOME must be set on Unix");
978 let base = PathBuf::from("/irrelevant");
979 let result = AppSettings::expand_path(std::path::Path::new("~"), &base);
980 assert!(result.is_absolute());
981 let expected = PathBuf::from(&home)
983 .canonicalize()
984 .unwrap_or(PathBuf::from(&home));
985 assert_eq!(result, expected);
986 }
987
988 #[test]
989 #[cfg(windows)]
990 fn expand_path_tilde_uses_userprofile_windows() {
991 let home = std::env::var("USERPROFILE").expect("USERPROFILE must be set on Windows");
992 let base = PathBuf::from("C:\\irrelevant");
993 let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
994 assert!(result.is_absolute());
995 assert!(
996 result.starts_with(&home),
997 "expected path to start with USERPROFILE={}, got {:?}",
998 home,
999 result
1000 );
1001 }
1002
1003 #[test]
1004 fn resolve_paths_populates_resolved_path() {
1005 let base = tempfile::TempDir::new().unwrap();
1006 let notes = base.path().join("notes");
1007 std::fs::create_dir_all(¬es).unwrap();
1008
1009 let toml = r#"
1010config_version = 2
1011[global]
1012current_workspace = "test"
1013[workspaces.test]
1014path = "notes"
1015last_paths = []
1016created = "2026-01-01T00:00:00Z"
1017"#
1018 .to_string();
1019 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1020 settings.resolve_paths(base.path());
1021
1022 let wc = settings.workspace_config.as_ref().unwrap();
1023 let entry = wc.workspaces.get("test").unwrap();
1024 assert_eq!(entry.path, PathBuf::from("notes"));
1026 assert!(entry.resolved_path.is_some());
1028 assert!(entry.effective_path().is_absolute());
1029 }
1030
1031 #[test]
1032 fn resolve_paths_absolute_no_resolved_path() {
1033 let toml = r#"
1034config_version = 2
1035[global]
1036current_workspace = "test"
1037[workspaces.test]
1038path = "/absolute/notes"
1039last_paths = []
1040created = "2026-01-01T00:00:00Z"
1041"#;
1042 let mut settings: AppSettings = toml::from_str(toml).unwrap();
1043 settings.resolve_paths(std::path::Path::new("/config"));
1044
1045 let wc = settings.workspace_config.as_ref().unwrap();
1046 let entry = wc.workspaces.get("test").unwrap();
1047 assert!(entry.resolved_path.is_none());
1049 assert_eq!(*entry.effective_path(), PathBuf::from("/absolute/notes"));
1050 }
1051}
1052
1053#[cfg(test)]
1054mod sort_settings_tests {
1055 use super::*;
1056
1057 #[test]
1058 fn group_directories_defaults_off() {
1059 let s = AppSettings::default();
1060 assert!(!s.group_directories);
1061 }
1062
1063 #[test]
1064 fn open_sort_dialog_is_bound_by_default() {
1065 let s = AppSettings::default();
1066 let map = s.key_bindings.to_hashmap();
1067 assert!(
1068 map.contains_key(&ActionShortcuts::OpenSortDialog),
1069 "OpenSortDialog must have a default binding"
1070 );
1071 }
1072}