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
12/// Errors from loading and saving settings and themes. Typed at this seam so
13/// callers can match on the failure; the binary's eyre top level (CLI, main)
14/// wraps it automatically via `?`.
15#[derive(Debug, thiserror::Error)]
16pub enum SettingsError {
17    #[error(transparent)]
18    Io(#[from] std::io::Error),
19    #[error("cannot serialize settings: {0}")]
20    Serialize(#[from] toml::ser::Error),
21    #[error("corrupt theme file: {0}")]
22    CorruptTheme(toml::de::Error),
23    #[error("config migration failed: {0}")]
24    Migration(String),
25}
26
27/// Shared settings handle — all screens and components reference the same instance.
28pub type SharedSettings = Arc<RwLock<AppSettings>>;
29use kimun_core::nfs::VaultPath;
30
31use crate::keys::KeyBindings;
32mod config_dir;
33pub(crate) use config_dir::get_home_dir;
34pub mod config_migration;
35pub mod history;
36pub mod icons;
37pub mod themes;
38pub mod workspace_config;
39
40// ---------------------------------------------------------------------------
41// Sort settings types (shared between AppSettings and sorting UI)
42// ---------------------------------------------------------------------------
43
44#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum SortFieldSetting {
47    Name,
48    Title,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum SortOrderSetting {
54    Ascending,
55    Descending,
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum EditorBackendSetting {
61    #[default]
62    Textarea,
63    Nvim,
64    Vim,
65}
66
67// pub mod theme;
68
69#[cfg(debug_assertions)]
70const CONFIG_DIR: &str = "kimun_debug";
71#[cfg(not(debug_assertions))]
72const CONFIG_DIR: &str = "kimun";
73
74/// Path to kimün's config directory (`~/.config/kimun`, or `kimun_debug` in
75/// debug builds), creating it if needed. Single source of truth for the
76/// debug/release directory name — used by the update module for the install
77/// marker and update-state file.
78pub fn config_dir() -> std::io::Result<PathBuf> {
79    get_or_create_config_dir(CONFIG_DIR)
80}
81
82const BASE_CONFIG_FILE: &str = "config.toml";
83const THEMES_DIR: &str = "themes";
84const CACHE_FILE_EXT: &str = "kimuncache";
85const HISTORY_FILE_EXT: &str = "txt";
86
87const CONFIG_HEADER: &str = "\
88# ─── Kimün configuration ────────────────────────────────────────────────────
89#
90# KEY BINDINGS
91# ────────────
92# Supported combinations:
93#   - ctrl and/or alt (with optional shift) + a letter (a-z)
94#   - bare F-key (F1–F12, no modifier required)
95# Any combo that does not follow these rules is silently ignored when loaded.
96#
97# Format per action:
98#   ActionName = [\"<modifiers> & <letter>\", ...]
99#
100# Available modifiers (combine with +):  ctrl   alt   shift
101#
102# Examples:
103#   Quit         = [\"ctrl&Q\"]            # Ctrl+Q
104#   SearchNotes  = [\"ctrl&K\"]            # Ctrl+K
105#   OpenNote     = [\"ctrl&O\"]            # Ctrl+O  (fuzzy file finder)
106#   OpenSettings = [\"F4\", \"ctrl&,\"]     # F4 (Ctrl+, alias)
107#   NewJournal   = [\"ctrl&J\"]            # Ctrl+J
108#   FileOperations = [\"F2\"]              # F2  (open file-ops menu: delete/rename/move)
109#   Leader       = [\"ctrl&G\"]            # Ctrl+G  (leader gateway: Ctrl+G f f, ...)
110#   OpenCommandPalette = [\"ctrl&P\"]      # Ctrl+P  (every leader command, fuzzy)
111#
112# OTHER SETTINGS
113# ──────────────
114#   theme             = \"Gruvbox Dark\"   # or any built-in / custom theme name
115#   leader_timeout_ms = 400               # hesitation before the which-key menu
116#
117# LEADER TREE OVERRIDES
118# ─────────────────────
119#   Remap, add, or remove leader sequences ([leader.bind]) and rename group
120#   captions ([leader.labels]). Keys are the sequence AFTER the gateway;
121#   bind values are action ids (see the cheatsheet) or \"none\" to unbind.
122#   [leader.bind]
123#   \"o f\" = \"find.files\"     # remap: leader o f now opens the file picker
124#   \"x\"   = \"note.daily\"     # add:   leader x opens today's journal
125#   \"g p\" = \"none\"           # remove the git-sync stub binding
126#   [leader.labels]
127#   \"f\"   = \"+search\"        # rename the +find group caption
128#
129# ─────────────────────────────────────────────────────────────────────────────
130";
131
132#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
133pub struct AppSettings {
134    // Phase 2 config
135    #[serde(default)]
136    pub config_version: u32,
137    #[serde(flatten, skip_serializing_if = "Option::is_none")]
138    pub workspace_config: Option<WorkspaceConfig>,
139
140    // Legacy Phase 1 fields — only kept for migration detection/deserialization.
141    // Never written back: workspace_dir is taken by migration, last_paths is
142    // moved into workspace_config entries.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub workspace_dir: Option<PathBuf>,
145    #[serde(default, skip_serializing)]
146    pub last_paths: Vec<VaultPath>,
147
148    // Preserved fields
149    #[serde(default)]
150    pub theme: String,
151    #[serde(default = "default_cache_dir")]
152    pub cache_dir: PathBuf,
153    #[serde(skip)]
154    cache_dir_resolved: Option<PathBuf>,
155
156    #[serde(default = "default_history_dir")]
157    pub history_dir: PathBuf,
158    #[serde(skip)]
159    history_dir_resolved: Option<PathBuf>,
160    #[serde(skip, default = "yes")]
161    needs_indexing: bool,
162    #[serde(default = "default_keybindings")]
163    pub key_bindings: KeyBindings,
164    #[serde(default = "default_autosave_interval")]
165    pub autosave_interval_secs: u64,
166    /// Hesitation timeout (ms) before the which-key overlay reveals itself
167    /// during a pending leader sequence. Sequences typed faster never wait.
168    #[serde(default = "default_leader_timeout_ms")]
169    pub leader_timeout_ms: u64,
170    /// Leader-tree customization: `[leader.bind]` sequence→action-id
171    /// overrides and `[leader.labels]` group captions. Applied over the
172    /// built-in tree.
173    #[serde(default)]
174    pub leader: LeaderConfig,
175    #[serde(default = "default_use_nerd_fonts")]
176    pub use_nerd_fonts: bool,
177    #[serde(default)]
178    pub editor_backend: EditorBackendSetting,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub nvim_path: Option<std::path::PathBuf>,
181    #[serde(default = "default_sort_field")]
182    pub default_sort_field: SortFieldSetting,
183    #[serde(default = "default_sort_order")]
184    pub default_sort_order: SortOrderSetting,
185    #[serde(default = "default_journal_sort_field")]
186    pub journal_sort_field: SortFieldSetting,
187    #[serde(default = "default_journal_sort_order")]
188    pub journal_sort_order: SortOrderSetting,
189    #[serde(default)]
190    pub group_directories: bool,
191    /// Custom config file path. `None` means use the default location.
192    /// Not serialized — it's a runtime-only override.
193    #[serde(skip)]
194    pub config_file: Option<PathBuf>,
195}
196
197fn default_keybindings() -> KeyBindings {
198    let mut kb = KeyBindings::empty();
199    kb.batch_add()
200        .with_ctrl()
201        .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
202        .add(KeyStrike::KeyO, ActionShortcuts::OpenNote)
203        .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
204        .add(KeyStrike::KeyI, ActionShortcuts::Text(TextAction::Italic))
205        .add(
206            KeyStrike::KeyU,
207            ActionShortcuts::Text(TextAction::Underline),
208        )
209        .add(
210            KeyStrike::KeyS,
211            ActionShortcuts::Text(TextAction::Strikethrough),
212        )
213        .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Link))
214        .add(
215            KeyStrike::KeyT,
216            ActionShortcuts::Text(TextAction::ToggleHeader),
217        )
218        // =============================
219        // We add shift to the modifiers
220        // =============================
221        .with_shift()
222        .add(KeyStrike::KeyL, ActionShortcuts::Text(TextAction::Image));
223
224    // TUI navigation shortcuts (always Ctrl — terminal apps don't use Cmd/Meta).
225    // NOTE: the `Quit` entry must match `crate::keys::default_quit_combo()`,
226    // which the deserialize safety net uses to recover an unreachable app.
227    kb.batch_add()
228        .with_ctrl()
229        // Ctrl-P is the command palette (decision 2026-06-05); settings
230        // live on Ctrl+Shift+P.
231        .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
232        .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
233        .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
234        // Drawer toggle. Deliberate spec deviation: the spec's Tier-0 puts
235        // this on Ctrl-B, but Ctrl-B stays Bold (decision 2026-06-05) — the
236        // drawer toggle lives on Ctrl-T.
237        .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
238        .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
239        // Leader gateway. Spec deviation: spec says Ctrl-K, which stays the
240        // note browser; the gateway lives on Ctrl-G (decision 2026-06-05).
241        .add(KeyStrike::KeyG, ActionShortcuts::Leader)
242        // FollowLink's always-works binding; Ctrl+Enter also follows on
243        // kitty-protocol terminals (hardcoded in the editor screen).
244        .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
245        .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
246        .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
247        .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
248        // Ctrl-E opens (or switches the drawer to) the file browser; the
249        // pure drawer toggle is Ctrl-T above. ToggleQueryPanel has no
250        // default binding — FIND stays reachable via the rail and leader.
251        .add(KeyStrike::KeyE, ActionShortcuts::OpenFileBrowser)
252        .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer);
253
254    // Settings — F4 (no modifier, reliable in all terminals) plus the classic
255    // Ctrl+, kept as an alias. Ctrl+, doesn't transmit a distinct code on many
256    // terminals outside the kitty protocol, so F4 is the dependable default.
257    // (Ctrl+Shift+P collides with kitty's default hints-kitten chord prefix,
258    // which holds the screen mid-chord, so it isn't used.)
259    kb.batch_add()
260        .add(KeyStrike::F4, ActionShortcuts::OpenPreferences);
261    kb.batch_add()
262        .with_ctrl()
263        .add(KeyStrike::Comma, ActionShortcuts::OpenPreferences);
264
265    // File operations menu (F2 — no modifier, reliable in all terminals).
266    kb.batch_add()
267        .add(KeyStrike::F2, ActionShortcuts::FileOperations);
268
269    kb.batch_add()
270        .add(KeyStrike::F3, ActionShortcuts::OpenSavedSearches);
271
272    // Ask workspace (F6 — free key; the feature is inert without a server).
273    kb.batch_add().add(KeyStrike::F6, ActionShortcuts::OpenAsk);
274
275    // Workspace switcher — F5 (moved off F4, which is now Settings).
276    kb.batch_add()
277        .add(KeyStrike::F5, ActionShortcuts::SwitchWorkspace);
278
279    // Ctrl+D — save the current query to saved searches. Ctrl-only by design:
280    // Ctrl+Shift is unreliable on some terminals, Ctrl+S is taken by
281    // Strikethrough, and Ctrl+{A,C,X,Z} are claimed by the editor. Ctrl+D is
282    // the only free, terminal-safe Ctrl combo.
283    kb.batch_add()
284        .with_ctrl()
285        .add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
286
287    kb
288}
289
290fn yes() -> bool {
291    true
292}
293
294fn default_autosave_interval() -> u64 {
295    5
296}
297
298fn default_leader_timeout_ms() -> u64 {
299    400
300}
301
302/// The `[leader]` config section: binding overrides + group captions.
303#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
304pub struct LeaderConfig {
305    /// `[leader.bind]`: sequence (after the gateway, e.g. `"o f"` / `"x"`) →
306    /// action id (see the cheatsheet) or `"none"` to unbind.
307    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
308    pub bind: std::collections::BTreeMap<String, String>,
309    /// `[leader.labels]`: group sequence (e.g. `"f"`) → caption shown in the
310    /// which-key overlay and cheatsheet.
311    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
312    pub labels: std::collections::BTreeMap<String, String>,
313}
314
315impl AppSettings {
316    /// Suggested directory for a first workspace (`~/kimun-notes`). `None`
317    /// when the home directory cannot be determined.
318    pub fn default_workspace_suggestion() -> Option<PathBuf> {
319        config_dir::get_home_dir()
320            .ok()
321            .map(|h| h.join("kimun-notes"))
322    }
323
324    /// The leader tree with this config's `[leader]` overrides applied — the
325    /// ONE constructor every surface (engine, which-key, cheatsheet, palette)
326    /// must use, so they can never disagree.
327    pub fn leader_tree(&self) -> crate::keys::leader::LeaderNode {
328        let tree = crate::keys::leader::apply_overrides(
329            crate::keys::leader::leader_tree(),
330            self.leader
331                .bind
332                .iter()
333                .map(|(k, v)| (k.as_str(), v.as_str())),
334        );
335        crate::keys::leader::apply_labels(
336            tree,
337            self.leader
338                .labels
339                .iter()
340                .map(|(k, v)| (k.as_str(), v.as_str())),
341        )
342    }
343}
344
345fn default_cache_dir() -> PathBuf {
346    PathBuf::from(".")
347}
348
349fn default_history_dir() -> PathBuf {
350    PathBuf::from("history")
351}
352
353fn default_use_nerd_fonts() -> bool {
354    false
355}
356
357fn default_sort_field() -> SortFieldSetting {
358    SortFieldSetting::Name
359}
360
361fn default_sort_order() -> SortOrderSetting {
362    SortOrderSetting::Ascending
363}
364
365fn default_journal_sort_field() -> SortFieldSetting {
366    SortFieldSetting::Name
367}
368
369fn default_journal_sort_order() -> SortOrderSetting {
370    SortOrderSetting::Descending
371}
372
373impl Default for AppSettings {
374    fn default() -> Self {
375        Self {
376            config_version: 0,
377            workspace_config: None,
378            last_paths: vec![],
379            workspace_dir: None,
380            theme: Default::default(),
381            cache_dir: default_cache_dir(),
382            cache_dir_resolved: None,
383            history_dir: default_history_dir(),
384            history_dir_resolved: None,
385            needs_indexing: true,
386            key_bindings: default_keybindings(),
387            autosave_interval_secs: default_autosave_interval(),
388            leader_timeout_ms: default_leader_timeout_ms(),
389            leader: LeaderConfig::default(),
390            use_nerd_fonts: false,
391            editor_backend: EditorBackendSetting::Textarea,
392            nvim_path: None,
393            default_sort_field: default_sort_field(),
394            default_sort_order: default_sort_order(),
395            journal_sort_field: default_journal_sort_field(),
396            journal_sort_order: default_journal_sort_order(),
397            group_directories: false,
398            config_file: None,
399        }
400    }
401}
402
403impl AppSettings {
404    pub fn theme_list(&self) -> Vec<Theme> {
405        let mut list = Theme::builtins();
406        list.append(&mut Self::load_custom_themes());
407        // Merge the user's default.toml override if present.
408        if let Ok(custom_default) = Self::load_default_theme() {
409            list.push(custom_default);
410        }
411        list.sort_by(|a, b| a.name.cmp(&b.name));
412        list
413    }
414
415    fn default_config_file_path() -> Result<PathBuf, SettingsError> {
416        let config_home = get_or_create_config_dir(CONFIG_DIR)?;
417        Ok(config_home.join(BASE_CONFIG_FILE))
418    }
419
420    fn get_config_file_path(&self) -> Result<PathBuf, SettingsError> {
421        if let Some(ref path) = self.config_file {
422            Ok(path.clone())
423        } else {
424            Self::default_config_file_path()
425        }
426    }
427
428    fn get_themes_path() -> Result<PathBuf, SettingsError> {
429        let config_home = get_or_create_config_dir(CONFIG_DIR)?;
430        Ok(config_home.join(THEMES_DIR))
431    }
432
433    fn load_theme_from_path(path: &std::path::Path) -> Result<Theme, SettingsError> {
434        let theme_string = fs::read_to_string(path)?;
435        match toml::from_str::<Theme>(&theme_string) {
436            Ok(theme) => Ok(theme),
437            Err(e) => {
438                // Never delete a user-authored file over a typo — warn and
439                // skip, exactly like load_custom_themes does.
440                tracing::warn!("Skipping unparsable theme file {:?}: {}", path, e);
441                Err(SettingsError::CorruptTheme(e))
442            }
443        }
444    }
445
446    fn load_default_theme() -> Result<Theme, SettingsError> {
447        let theme_path = AppSettings::get_themes_path()?.join("default.toml");
448        Self::load_theme_from_path(&theme_path)
449    }
450
451    fn load_custom_themes() -> Vec<Theme> {
452        let mut themes = Vec::new();
453
454        // Get themes directory, return empty vec if it fails
455        let themes_path = match Self::get_themes_path() {
456            Ok(path) => path,
457            Err(_) => return themes,
458        };
459
460        // Read directory entries, return empty vec if it fails
461        let entries = match fs::read_dir(&themes_path) {
462            Ok(entries) => entries,
463            Err(_) => return themes,
464        };
465
466        // Iterate through all entries in the themes directory
467        for entry in entries.flatten() {
468            let path = entry.path();
469
470            // Skip if not a file
471            if !path.is_file() {
472                continue;
473            }
474
475            // Skip if not a .toml file
476            if path.extension().and_then(|s| s.to_str()) != Some("toml") {
477                continue;
478            }
479
480            // Skip default.toml
481            if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
482                continue;
483            }
484
485            // Try to read and deserialize the theme file
486            match fs::read_to_string(&path)
487                .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
488            {
489                Ok(theme) => themes.push(theme),
490                Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
491            }
492        }
493
494        themes
495    }
496
497    /// Whether the startup update check is enabled. Lives in `GlobalConfig`;
498    /// defaults to on when no workspace config exists yet. Single source for
499    /// the four read sites (startup, preferences, onboarding).
500    pub fn update_check(&self) -> bool {
501        self.workspace_config
502            .as_ref()
503            .map(|wc| wc.global.update_check)
504            .unwrap_or(true)
505    }
506
507    /// Whether kimün captures the mouse for in-app use; defaults on when no
508    /// workspace config exists yet. Read at startup (main.rs) and in preferences.
509    pub fn mouse(&self) -> bool {
510        self.workspace_config
511            .as_ref()
512            .map(|wc| wc.global.mouse)
513            .unwrap_or(true)
514    }
515
516    pub fn save_to_disk(&self) -> Result<(), SettingsError> {
517        tracing::debug!("Saving settings to disk");
518        let settings_file_path = self.get_config_file_path()?;
519        let mut file = File::create(settings_file_path)?;
520        file.write_all(CONFIG_HEADER.as_bytes())?;
521        let toml = toml::to_string(&self)?;
522        file.write_all(toml.as_bytes())?;
523        Ok(())
524    }
525
526    pub fn load_from_disk() -> Result<Self, SettingsError> {
527        let settings_file_path = Self::default_config_file_path()?;
528
529        if !settings_file_path.exists() {
530            let default_settings = Self::default();
531            default_settings.save_to_disk()?;
532            Ok(default_settings)
533        } else {
534            let mut settings_file = File::open(&settings_file_path)?;
535
536            let mut toml = String::new();
537            settings_file.read_to_string(&mut toml)?;
538
539            match toml::from_str::<AppSettings>(toml.as_ref()) {
540                Ok(mut setting) => {
541                    setting.config_file = Some(settings_file_path.clone());
542                    let config_dir = settings_file_path
543                        .parent()
544                        .unwrap_or(std::path::Path::new("."));
545                    setting.resolve_paths(config_dir);
546                    if config_migration::ConfigMigration::run(&mut setting)? {
547                        setting.save_to_disk()?;
548                    }
549                    setting.merge_missing_default_bindings();
550                    Ok(setting)
551                }
552                Err(e) => {
553                    tracing::warn!(
554                        "Config file at {:?} could not be parsed ({}). \
555                         Renaming to .corrupt and starting with defaults.",
556                        settings_file_path,
557                        e
558                    );
559                    let corrupt_path = settings_file_path.with_extension("toml.corrupt");
560                    let _ = fs::rename(&settings_file_path, &corrupt_path);
561                    let defaults = Self::default();
562                    defaults.save_to_disk()?;
563                    Ok(defaults)
564                }
565            }
566        }
567    }
568
569    pub fn load_from_file(path: PathBuf) -> Result<Self, SettingsError> {
570        if let Some(parent) = path.parent() {
571            fs::create_dir_all(parent)?;
572        }
573        if !path.exists() {
574            let default_settings = Self {
575                config_file: Some(path),
576                ..Self::default()
577            };
578            default_settings.save_to_disk()?;
579            return Ok(default_settings);
580        }
581        let mut toml_str = String::new();
582        File::open(&path)?.read_to_string(&mut toml_str)?;
583        match toml::from_str::<AppSettings>(&toml_str) {
584            Ok(mut setting) => {
585                setting.config_file = Some(path.clone());
586
587                // Resolve ~ and relative paths against the config file's directory.
588                let config_dir = path.parent().unwrap_or(std::path::Path::new("."));
589                setting.resolve_paths(config_dir);
590
591                // Run config migrations (e.g. Phase 1 → Phase 2 workspace_dir).
592                if config_migration::ConfigMigration::run(&mut setting)? {
593                    setting.save_to_disk()?;
594                }
595
596                setting.merge_missing_default_bindings();
597                Ok(setting)
598            }
599            Err(e) => {
600                tracing::warn!(
601                    "Config file at {:?} could not be parsed ({}). \
602                     Renaming to .corrupt and starting with defaults.",
603                    path,
604                    e
605                );
606                let corrupt_path = path.with_extension("toml.corrupt");
607                let _ = fs::rename(&path, &corrupt_path);
608                let defaults = Self {
609                    config_file: Some(path),
610                    ..Self::default()
611                };
612                defaults.save_to_disk()?;
613                Ok(defaults)
614            }
615        }
616    }
617
618    /// Fills in defaults from `default_keybindings()` that are absent in the
619    /// loaded config: actions with no binding at all, plus default combos
620    /// added in newer versions (e.g. Ctrl-B for the drawer toggle) — as long
621    /// as the combo is not already bound to *any* action. Existing
622    /// user-customised bindings are never overwritten.
623    fn merge_missing_default_bindings(&mut self) {
624        let defaults = default_keybindings().to_hashmap();
625        let mut current = self.key_bindings.to_hashmap();
626        let mut bound: std::collections::HashSet<_> = current.values().flatten().cloned().collect();
627        for (action, combos) in defaults {
628            match current.entry(action) {
629                std::collections::hash_map::Entry::Vacant(e) => {
630                    // Never steal a combo the user has bound to something
631                    // else — insert only the free ones, and claim them so a
632                    // later default in this pass cannot double-bind.
633                    let free: Vec<_> = combos.into_iter().filter(|c| !bound.contains(c)).collect();
634                    if !free.is_empty() {
635                        bound.extend(free.iter().copied());
636                        e.insert(free);
637                    }
638                }
639                std::collections::hash_map::Entry::Occupied(mut e) => {
640                    for combo in combos {
641                        if !bound.contains(&combo) && !e.get().contains(&combo) {
642                            bound.insert(combo);
643                            e.get_mut().push(combo);
644                        }
645                    }
646                }
647            }
648        }
649        self.key_bindings = KeyBindings::from_hashmap(current);
650    }
651
652    // We set a new workspace to work with, remember to save the data
653    // to persist it in disk
654    pub fn set_workspace(&mut self, workspace_path: &PathBuf) {
655        if let Some(current_workspace_dir) = &self.workspace_dir
656            && workspace_path != current_workspace_dir
657        {
658            self.needs_indexing = true;
659        }
660
661        self.workspace_dir = Some(workspace_path.to_owned());
662    }
663
664    /// Removes the active workspace path so the user is prompted to choose a new one.
665    /// Handles both Phase 1 (workspace_dir) and Phase 2 (workspace_config) config formats.
666    ///
667    /// For Phase 2: only the currently active workspace entry is removed; other workspace
668    /// entries in the config are preserved. After this call, `workspace_config` remains
669    /// `Some` but `get_current_workspace()` returns `None`.
670    pub fn clear_workspace(&mut self) {
671        // Phase 1
672        if self.workspace_dir.is_some() {
673            self.workspace_dir = None;
674            self.needs_indexing = true;
675        }
676        // Phase 2
677        if let Some(wc) = &mut self.workspace_config {
678            let key = wc.global.current_workspace.clone();
679            if !key.is_empty() {
680                wc.workspaces.remove(&key);
681            }
682            wc.global.current_workspace = String::new();
683        }
684    }
685
686    /// Resolve the active workspace path from Phase 2 (workspace_config) or
687    /// Phase 1 (workspace_dir). Returns `None` if no workspace is configured.
688    pub fn resolve_workspace_path(&self) -> Option<PathBuf> {
689        self.workspace_config
690            .as_ref()
691            .and_then(|wc| wc.get_current_workspace())
692            .map(|entry| entry.effective_path().clone())
693            .or_else(|| self.workspace_dir.clone())
694    }
695
696    /// Resolve `~` and relative paths in workspace entries.
697    /// Relative paths are resolved against `base` (typically the config file's
698    /// parent directory). Called once after deserialization.
699    fn resolve_paths(&mut self, base: &std::path::Path) {
700        // Legacy workspace_dir — resolve in place (it's a legacy field that
701        // gets consumed by migration anyway).
702        if let Some(ref mut p) = self.workspace_dir {
703            *p = Self::expand_path(p, base);
704        }
705        // Phase 2 workspace entries — populate resolved_path, keep original path intact.
706        if let Some(ref mut wc) = self.workspace_config {
707            for entry in wc.workspaces.values_mut() {
708                let resolved = Self::expand_path(&entry.path, base);
709                if resolved != entry.path {
710                    entry.resolved_path = Some(resolved);
711                }
712            }
713        }
714        self.cache_dir_resolved = Some(Self::expand_path(&self.cache_dir, base));
715        self.history_dir_resolved = Some(Self::expand_path(&self.history_dir, base));
716    }
717
718    /// Expand `~` to the home directory and resolve relative paths against `base`.
719    /// Returns an absolute path. If the resolved path exists on disk, it is
720    /// canonicalized to remove `.` and `..` components.
721    fn expand_path(path: &std::path::Path, base: &std::path::Path) -> PathBuf {
722        let s = path.to_string_lossy();
723        let expanded = if s.starts_with("~/") || s == "~" {
724            if let Ok(home) = config_dir::get_home_dir() {
725                home.join(s.strip_prefix("~/").unwrap_or(""))
726            } else {
727                path.to_path_buf()
728            }
729        } else {
730            path.to_path_buf()
731        };
732        let absolute = if expanded.is_relative() {
733            base.join(expanded)
734        } else {
735            expanded
736        };
737        // Canonicalize if the path exists, otherwise return as-is.
738        absolute.canonicalize().unwrap_or(absolute)
739    }
740
741    pub fn set_theme(&mut self, theme: String) {
742        self.theme = theme;
743    }
744
745    pub fn report_indexed(&mut self) {
746        self.needs_indexing = false;
747    }
748
749    pub fn needs_indexing(&self) -> bool {
750        self.needs_indexing
751    }
752
753    pub fn add_path_history(&mut self, note_path: &VaultPath) {
754        if !note_path.is_note() {
755            return;
756        }
757        let Some(workspace_name) = self.current_workspace_name() else {
758            return;
759        };
760        let file_path = self.history_path_for(&workspace_name);
761        if let Err(e) = history::push_history(&file_path, note_path) {
762            tracing::warn!("failed to write history {:?}: {}", file_path, e);
763        }
764    }
765
766    pub fn current_workspace_name(&self) -> Option<String> {
767        self.workspace_config
768            .as_ref()
769            .map(|wc| wc.global.current_workspace.clone())
770            .filter(|s| !s.is_empty())
771    }
772
773    pub fn cache_dir_resolved(&self) -> Option<&Path> {
774        self.cache_dir_resolved.as_deref()
775    }
776
777    pub fn history_dir_resolved(&self) -> Option<&Path> {
778        self.history_dir_resolved.as_deref()
779    }
780
781    /// Path to the SQLite cache file for the named workspace.
782    /// Caller must have already validated `workspace_name` via
783    /// `kimun_core::nfs::filename::validate_filename`.
784    pub fn cache_path_for(&self, workspace_name: &str) -> PathBuf {
785        Self::workspace_file(
786            self.cache_dir_resolved.as_ref().unwrap_or(&self.cache_dir),
787            workspace_name,
788            CACHE_FILE_EXT,
789        )
790    }
791
792    /// Path to the history file for the named workspace.
793    /// Caller must have already validated `workspace_name`.
794    pub fn history_path_for(&self, workspace_name: &str) -> PathBuf {
795        Self::workspace_file(
796            self.history_dir_resolved
797                .as_ref()
798                .unwrap_or(&self.history_dir),
799            workspace_name,
800            HISTORY_FILE_EXT,
801        )
802    }
803
804    fn workspace_file(dir: &Path, workspace_name: &str, ext: &str) -> PathBuf {
805        dir.join(format!("{workspace_name}.{ext}"))
806    }
807
808    /// Returns the last-visited paths for the current workspace.
809    pub fn current_last_paths(&self) -> Vec<VaultPath> {
810        let Some(name) = self.current_workspace_name() else {
811            return Vec::new();
812        };
813        let file_path = self.history_path_for(&name);
814        history::load_history(&file_path)
815    }
816
817    /// Build the icon set for the current `use_nerd_fonts` setting.
818    pub fn icons(&self) -> icons::Icons {
819        icons::Icons::new(self.use_nerd_fonts)
820    }
821
822    /// Name of the theme the app is effectively using: the configured name,
823    /// or the default theme's name when none is configured. Single owner of
824    /// the empty-name fallback rule — use this instead of re-deriving it.
825    pub fn effective_theme_name(&self) -> String {
826        if self.theme.is_empty() {
827            Theme::default().name
828        } else {
829            self.theme.clone()
830        }
831    }
832
833    /// Resolve the active theme by name, falling back to the default.
834    ///
835    /// The resolved theme is adapted to the terminal's color depth (truecolor
836    /// themes are quantized on 256-color terminals and mapped to role-semantic
837    /// ANSI slots on 16-color terminals).
838    pub fn get_theme(&self) -> Theme {
839        let theme = if self.theme.is_empty() {
840            Theme::default()
841        } else {
842            self.theme_list()
843                .into_iter()
844                .find(|t| t.name == self.theme)
845                .unwrap_or_default()
846        };
847        theme.adapt_to_terminal()
848    }
849}
850
851#[cfg(test)]
852#[allow(clippy::field_reassign_with_default)]
853mod tests {
854    use super::*;
855
856    #[test]
857    fn default_workspace_suggestion_is_under_home() {
858        let suggestion = AppSettings::default_workspace_suggestion();
859        if let Some(p) = suggestion {
860            assert!(p.ends_with("kimun-notes"));
861            assert!(p.is_absolute());
862        }
863        // None is acceptable only when the platform has no home dir.
864    }
865
866    #[test]
867    fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
868        // RED: fails to compile because load_theme_from_path doesn't exist.
869        // GREEN: method exists, returns Err, and does NOT create the file.
870        let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
871        let _ = std::fs::remove_file(&path); // ensure clean state
872
873        let result = AppSettings::load_theme_from_path(&path);
874
875        assert!(result.is_err(), "should return Err when file is absent");
876        assert!(!path.exists(), "must not create the file as a side effect");
877    }
878
879    #[test]
880    fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
881        // After a corrupt file is removed, no replacement must be written.
882        let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
883        std::fs::write(&path, b"not valid toml {{{{").unwrap();
884
885        let result = AppSettings::load_theme_from_path(&path);
886
887        assert!(result.is_err(), "should return Err for corrupt TOML");
888        // The user's file must SURVIVE a parse error (a typo must never
889        // delete a hand-authored theme).
890        assert!(path.exists(), "corrupt theme file must not be deleted");
891        std::fs::remove_file(&path).ok();
892    }
893
894    #[test]
895    fn default_keybindings_quit_matches_canonical_combo() {
896        let kb = default_keybindings();
897        let combo = crate::keys::default_quit_combo();
898        assert_eq!(
899            kb.get_action(&combo),
900            Some(ActionShortcuts::Quit),
901            "default_keybindings() must bind default_quit_combo() to Quit so the \
902             deserialize safety net can recover an unreachable app"
903        );
904    }
905
906    #[test]
907    fn autosave_interval_defaults_to_five() {
908        let settings = AppSettings::default();
909        assert_eq!(settings.autosave_interval_secs, 5);
910    }
911
912    #[test]
913    fn autosave_interval_deserializes_from_toml() {
914        let toml = "autosave_interval_secs = 30\n";
915        let settings: AppSettings = toml::from_str(toml).unwrap();
916        assert_eq!(settings.autosave_interval_secs, 30);
917    }
918
919    #[test]
920    fn autosave_interval_defaults_when_missing_from_toml() {
921        let toml = ""; // no autosave_interval_secs key
922        let settings: AppSettings = toml::from_str(toml).unwrap();
923        assert_eq!(settings.autosave_interval_secs, 5);
924    }
925
926    /// Verify the full load path: TOML with FileOperations = ["F2"] → keybinding lookup.
927    #[test]
928    fn f2_file_operations_survives_toml_deserialize() {
929        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
930        use crate::keys::key_strike::KeyStrike;
931
932        let toml = r#"
933[key_bindings]
934FileOperations = ["F2"]
935"#;
936        let settings: AppSettings = toml::from_str(toml).unwrap();
937        let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
938        let action = settings.key_bindings.get_action(&f2);
939        assert_eq!(
940            action,
941            Some(ActionShortcuts::FileOperations),
942            "F2 should survive deserialization and map to FileOperations"
943        );
944    }
945
946    /// Verify merge_missing_default_bindings adds F2 when absent from config.
947    #[test]
948    fn merge_adds_f2_when_absent() {
949        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
950        use crate::keys::key_strike::KeyStrike;
951
952        // Settings with no FileOperations binding
953        let toml = r#"
954[key_bindings]
955Quit = ["ctrl&Q"]
956"#;
957        let mut settings: AppSettings = toml::from_str(toml).unwrap();
958        settings.merge_missing_default_bindings();
959
960        let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
961        let action = settings.key_bindings.get_action(&f2);
962        assert_eq!(
963            action,
964            Some(ActionShortcuts::FileOperations),
965            "merge_missing_default_bindings should add F2 → FileOperations"
966        );
967    }
968
969    #[test]
970    fn clear_workspace_phase1_clears_workspace_dir() {
971        let mut settings = AppSettings::default();
972        settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
973        settings.needs_indexing = false;
974        settings.clear_workspace();
975        assert!(
976            settings.workspace_dir.is_none(),
977            "workspace_dir should be None"
978        );
979        assert!(
980            settings.needs_indexing,
981            "needs_indexing should be reset to true"
982        );
983    }
984
985    #[test]
986    fn clear_workspace_phase2_removes_current_workspace_entry() {
987        let mut settings = AppSettings::default();
988        let mut wc = WorkspaceConfig::new_empty();
989        wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
990            .unwrap();
991        settings.workspace_config = Some(wc);
992        // Assert precondition: add_workspace auto-selects the first workspace
993        assert_eq!(
994            settings
995                .workspace_config
996                .as_ref()
997                .unwrap()
998                .global
999                .current_workspace,
1000            "vault1"
1001        );
1002        settings.clear_workspace();
1003        let wc = settings.workspace_config.as_ref().unwrap();
1004        assert!(
1005            wc.workspaces.is_empty(),
1006            "workspace entry should be removed"
1007        );
1008        assert!(
1009            wc.global.current_workspace.is_empty(),
1010            "current_workspace should be empty"
1011        );
1012    }
1013
1014    #[test]
1015    fn clear_workspace_both_phases_active() {
1016        // When Phase 1 and Phase 2 fields are both populated (e.g. during migration),
1017        // clear_workspace must clear both independently.
1018        let mut settings = AppSettings::default();
1019        settings.workspace_dir = Some(PathBuf::from("/tmp/vault"));
1020        let mut wc = WorkspaceConfig::new_empty();
1021        wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
1022            .unwrap();
1023        settings.workspace_config = Some(wc);
1024        settings.clear_workspace();
1025        assert!(
1026            settings.workspace_dir.is_none(),
1027            "phase1 workspace_dir should be cleared"
1028        );
1029        let wc = settings.workspace_config.as_ref().unwrap();
1030        assert!(
1031            wc.workspaces.is_empty(),
1032            "phase2 workspace entry should be removed"
1033        );
1034        assert!(
1035            wc.global.current_workspace.is_empty(),
1036            "phase2 current_workspace should be empty"
1037        );
1038    }
1039
1040    #[test]
1041    fn clear_workspace_phase2_preserves_other_workspaces() {
1042        let mut settings = AppSettings::default();
1043        let mut wc = WorkspaceConfig::new_empty();
1044        wc.add_workspace("vault1".to_string(), PathBuf::from("/tmp/vault1"))
1045            .unwrap();
1046        wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
1047            .unwrap();
1048        wc.global.current_workspace = "vault1".to_string();
1049        settings.workspace_config = Some(wc);
1050        settings.clear_workspace();
1051        let wc = settings.workspace_config.as_ref().unwrap();
1052        assert!(
1053            !wc.workspaces.contains_key("vault1"),
1054            "active workspace should be removed"
1055        );
1056        assert!(
1057            wc.workspaces.contains_key("vault2"),
1058            "other workspaces should be preserved"
1059        );
1060        assert!(
1061            wc.global.current_workspace.is_empty(),
1062            "current_workspace should be empty"
1063        );
1064    }
1065}
1066
1067#[cfg(test)]
1068mod backend_tests {
1069    use super::*;
1070
1071    #[test]
1072    fn default_backend_is_textarea() {
1073        let settings = AppSettings::default();
1074        assert!(matches!(
1075            settings.editor_backend,
1076            EditorBackendSetting::Textarea
1077        ));
1078    }
1079
1080    #[test]
1081    fn nvim_backend_round_trips_toml() {
1082        let toml = "editor_backend = \"nvim\"\n";
1083        let parsed: AppSettings = toml::from_str(toml).unwrap();
1084        assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
1085    }
1086
1087    #[test]
1088    fn editor_backend_vim_roundtrips_through_toml() {
1089        #[derive(serde::Serialize, serde::Deserialize)]
1090        struct W {
1091            editor_backend: EditorBackendSetting,
1092        }
1093        let w = W {
1094            editor_backend: EditorBackendSetting::Vim,
1095        };
1096        let s = toml::to_string(&w).unwrap();
1097        assert!(s.contains("editor_backend = \"vim\""), "serialized: {s}");
1098        let back: W = toml::from_str(&s).unwrap();
1099        assert_eq!(back.editor_backend, EditorBackendSetting::Vim);
1100    }
1101
1102    // ── expand_path tests ──────────────────────────────────────────────
1103
1104    #[test]
1105    fn expand_path_absolute_unchanged() {
1106        let base = PathBuf::from("/config/dir");
1107        let result = AppSettings::expand_path(std::path::Path::new("/absolute/path/notes"), &base);
1108        assert!(result.is_absolute());
1109        assert!(result.to_string_lossy().contains("absolute"));
1110    }
1111
1112    #[test]
1113    fn expand_path_relative_resolved_against_base() {
1114        let base = tempfile::TempDir::new().unwrap();
1115        let notes = base.path().join("notes");
1116        std::fs::create_dir_all(&notes).unwrap();
1117
1118        let result = AppSettings::expand_path(std::path::Path::new("notes"), base.path());
1119        assert!(result.is_absolute());
1120        assert_eq!(result, notes.canonicalize().unwrap());
1121    }
1122
1123    #[test]
1124    fn expand_path_relative_with_dotdot() {
1125        let base = tempfile::TempDir::new().unwrap();
1126        let sibling = base.path().join("sibling");
1127        std::fs::create_dir_all(&sibling).unwrap();
1128        let sub = base.path().join("sub");
1129        std::fs::create_dir_all(&sub).unwrap();
1130
1131        let result = AppSettings::expand_path(std::path::Path::new("../sibling"), &sub);
1132        assert!(result.is_absolute());
1133        assert_eq!(result, sibling.canonicalize().unwrap());
1134    }
1135
1136    #[test]
1137    fn expand_path_nonexistent_relative_still_absolute() {
1138        let base = PathBuf::from("/some/config/dir");
1139        let result = AppSettings::expand_path(std::path::Path::new("my-notes"), &base);
1140        assert!(result.is_absolute());
1141        assert_eq!(result, PathBuf::from("/some/config/dir/my-notes"));
1142    }
1143
1144    #[test]
1145    #[cfg(unix)]
1146    fn expand_path_tilde_uses_home_unix() {
1147        let home = std::env::var("HOME").expect("HOME must be set on Unix");
1148        let base = PathBuf::from("/irrelevant");
1149        let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
1150        assert!(result.is_absolute());
1151        assert!(
1152            result.starts_with(&home),
1153            "expected path to start with HOME={}, got {:?}",
1154            home,
1155            result
1156        );
1157        assert!(result.to_string_lossy().contains("Documents/notes"));
1158    }
1159
1160    #[test]
1161    #[cfg(unix)]
1162    fn expand_path_tilde_alone_is_home_unix() {
1163        let home = std::env::var("HOME").expect("HOME must be set on Unix");
1164        let base = PathBuf::from("/irrelevant");
1165        let result = AppSettings::expand_path(std::path::Path::new("~"), &base);
1166        assert!(result.is_absolute());
1167        // canonicalize may resolve symlinks, so compare canonicalized forms
1168        let expected = PathBuf::from(&home)
1169            .canonicalize()
1170            .unwrap_or(PathBuf::from(&home));
1171        assert_eq!(result, expected);
1172    }
1173
1174    #[test]
1175    #[cfg(windows)]
1176    fn expand_path_tilde_uses_userprofile_windows() {
1177        let home = std::env::var("USERPROFILE").expect("USERPROFILE must be set on Windows");
1178        let base = PathBuf::from("C:\\irrelevant");
1179        let result = AppSettings::expand_path(std::path::Path::new("~/Documents/notes"), &base);
1180        assert!(result.is_absolute());
1181        assert!(
1182            result.starts_with(&home),
1183            "expected path to start with USERPROFILE={}, got {:?}",
1184            home,
1185            result
1186        );
1187    }
1188
1189    #[test]
1190    fn resolve_paths_populates_resolved_path() {
1191        let base = tempfile::TempDir::new().unwrap();
1192        let notes = base.path().join("notes");
1193        std::fs::create_dir_all(&notes).unwrap();
1194
1195        let toml = r#"
1196config_version = 2
1197[global]
1198current_workspace = "test"
1199[workspaces.test]
1200path = "notes"
1201last_paths = []
1202created = "2026-01-01T00:00:00Z"
1203"#
1204        .to_string();
1205        let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1206        settings.resolve_paths(base.path());
1207
1208        let wc = settings.workspace_config.as_ref().unwrap();
1209        let entry = wc.workspaces.get("test").unwrap();
1210        // Original path preserved
1211        assert_eq!(entry.path, PathBuf::from("notes"));
1212        // Resolved path is absolute
1213        assert!(entry.resolved_path.is_some());
1214        assert!(entry.effective_path().is_absolute());
1215    }
1216
1217    #[test]
1218    fn resolve_paths_absolute_no_resolved_path() {
1219        let toml = r#"
1220config_version = 2
1221[global]
1222current_workspace = "test"
1223[workspaces.test]
1224path = "/absolute/notes"
1225last_paths = []
1226created = "2026-01-01T00:00:00Z"
1227"#;
1228        let mut settings: AppSettings = toml::from_str(toml).unwrap();
1229        settings.resolve_paths(std::path::Path::new("/config"));
1230
1231        let wc = settings.workspace_config.as_ref().unwrap();
1232        let entry = wc.workspaces.get("test").unwrap();
1233        // No resolved_path needed for already-absolute paths
1234        assert!(entry.resolved_path.is_none());
1235        assert_eq!(*entry.effective_path(), PathBuf::from("/absolute/notes"));
1236    }
1237}
1238
1239#[cfg(test)]
1240mod sort_settings_tests {
1241    use super::*;
1242
1243    #[test]
1244    fn group_directories_defaults_off() {
1245        let s = AppSettings::default();
1246        assert!(!s.group_directories);
1247    }
1248
1249    #[test]
1250    fn open_sort_dialog_is_bound_by_default() {
1251        let s = AppSettings::default();
1252        let map = s.key_bindings.to_hashmap();
1253        assert!(
1254            map.contains_key(&ActionShortcuts::OpenSortDialog),
1255            "OpenSortDialog must have a default binding"
1256        );
1257    }
1258}