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