Skip to main content

kimun_notes/settings/
mod.rs

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
14/// Shared settings handle — all screens and components reference the same instance.
15pub 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// ---------------------------------------------------------------------------
27// Sort settings types (shared between AppSettings and sorting UI)
28// ---------------------------------------------------------------------------
29
30#[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// pub mod theme;
53
54#[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    // Phase 2 config
93    #[serde(default)]
94    pub config_version: u32,
95    #[serde(flatten, skip_serializing_if = "Option::is_none")]
96    pub workspace_config: Option<WorkspaceConfig>,
97
98    // Legacy Phase 1 fields — only kept for migration detection/deserialization.
99    // Never written back: workspace_dir is taken by migration, last_paths is
100    // moved into workspace_config entries.
101    #[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    // Preserved fields
107    #[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    /// Custom config file path. `None` means use the default location.
141    /// Not serialized — it's a runtime-only override.
142    #[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        // =============================
169        // We add shift to the modifiers
170        // =============================
171        .with_shift()
172        .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
173
174    // TUI navigation shortcuts (always Ctrl — terminal apps don't use Cmd/Meta).
175    // NOTE: the `Quit` entry must match `crate::keys::default_quit_combo()`,
176    // which the deserialize safety net uses to recover an unreachable app.
177    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    // File operations menu (F2 — no modifier, reliable in all terminals).
192    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    // Ctrl+D — save the current query to saved searches. Ctrl-only by design:
202    // Ctrl+Shift is unreliable on some terminals, Ctrl+S is taken by
203    // Strikethrough, and Ctrl+{A,C,X,Z} are claimed by the editor. Ctrl+D is
204    // the only free, terminal-safe Ctrl combo.
205    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        // Merge the user's default.toml override if present.
303        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        // Get themes directory, return empty vec if it fails
353        let themes_path = match Self::get_themes_path() {
354            Ok(path) => path,
355            Err(_) => return themes,
356        };
357
358        // Read directory entries, return empty vec if it fails
359        let entries = match fs::read_dir(&themes_path) {
360            Ok(entries) => entries,
361            Err(_) => return themes,
362        };
363
364        // Iterate through all entries in the themes directory
365        for entry in entries.flatten() {
366            let path = entry.path();
367
368            // Skip if not a file
369            if !path.is_file() {
370                continue;
371            }
372
373            // Skip if not a .toml file
374            if path.extension().and_then(|s| s.to_str()) != Some("toml") {
375                continue;
376            }
377
378            // Skip default.toml
379            if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
380                continue;
381            }
382
383            // Try to read and deserialize the theme file
384            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                // Resolve ~ and relative paths against the config file's directory.
467                let config_dir = path.parent().unwrap_or(std::path::Path::new("."));
468                setting.resolve_paths(config_dir);
469
470                // Run config migrations (e.g. Phase 1 → Phase 2 workspace_dir).
471                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    /// Fills in any actions from `default_keybindings()` that are absent in the loaded config.
498    /// Existing user-customised bindings are never overwritten.
499    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    // We set a new workspace to work with, remember to save the data
509    // to persist it in disk
510    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    /// Removes the active workspace path so the user is prompted to choose a new one.
521    /// Handles both Phase 1 (workspace_dir) and Phase 2 (workspace_config) config formats.
522    ///
523    /// For Phase 2: only the currently active workspace entry is removed; other workspace
524    /// entries in the config are preserved. After this call, `workspace_config` remains
525    /// `Some` but `get_current_workspace()` returns `None`.
526    pub fn clear_workspace(&mut self) {
527        // Phase 1
528        if self.workspace_dir.is_some() {
529            self.workspace_dir = None;
530            self.needs_indexing = true;
531        }
532        // Phase 2
533        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    /// Resolve the active workspace path from Phase 2 (workspace_config) or
543    /// Phase 1 (workspace_dir). Returns `None` if no workspace is configured.
544    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    /// Resolve `~` and relative paths in workspace entries.
553    /// Relative paths are resolved against `base` (typically the config file's
554    /// parent directory). Called once after deserialization.
555    fn resolve_paths(&mut self, base: &std::path::Path) {
556        // Legacy workspace_dir — resolve in place (it's a legacy field that
557        // gets consumed by migration anyway).
558        if let Some(ref mut p) = self.workspace_dir {
559            *p = Self::expand_path(p, base);
560        }
561        // Phase 2 workspace entries — populate resolved_path, keep original path intact.
562        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    /// Expand `~` to the home directory and resolve relative paths against `base`.
575    /// Returns an absolute path. If the resolved path exists on disk, it is
576    /// canonicalized to remove `.` and `..` components.
577    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        // Canonicalize if the path exists, otherwise return as-is.
594        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    /// Path to the SQLite cache file for the named workspace.
638    /// Caller must have already validated `workspace_name` via
639    /// `kimun_core::nfs::filename::validate_filename`.
640    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    /// Path to the history file for the named workspace.
649    /// Caller must have already validated `workspace_name`.
650    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    /// Returns the last-visited paths for the current workspace.
665    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    /// Build the icon set for the current `use_nerd_fonts` setting.
674    pub fn icons(&self) -> icons::Icons {
675        icons::Icons::new(self.use_nerd_fonts)
676    }
677
678    /// Resolve the active theme by name, falling back to the default.
679    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        // RED: fails to compile because load_theme_from_path doesn't exist.
698        // GREEN: method exists, returns Err, and does NOT create the file.
699        let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
700        let _ = std::fs::remove_file(&path); // ensure clean state
701
702        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        // After a corrupt file is removed, no replacement must be written.
711        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 = ""; // no autosave_interval_secs key
751        let settings: AppSettings = toml::from_str(toml).unwrap();
752        assert_eq!(settings.autosave_interval_secs, 5);
753    }
754
755    /// Verify the full load path: TOML with FileOperations = ["F2"] → keybinding lookup.
756    #[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    /// Verify merge_missing_default_bindings adds F2 when absent from config.
776    #[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        // Settings with no FileOperations binding
782        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 precondition: add_workspace auto-selects the first workspace
822        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        // When Phase 1 and Phase 2 fields are both populated (e.g. during migration),
846        // clear_workspace must clear both independently.
847        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    // ── expand_path tests ──────────────────────────────────────────────
917
918    #[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(&notes).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        // canonicalize may resolve symlinks, so compare canonicalized forms
982        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(&notes).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        // Original path preserved
1025        assert_eq!(entry.path, PathBuf::from("notes"));
1026        // Resolved path is absolute
1027        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        // No resolved_path needed for already-absolute paths
1048        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}