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